Compare commits

...

94 Commits

Author SHA1 Message Date
github-actions[bot]
de304bb9b6 chore: add VERSION v0.4.10 2025-11-07 14:00:15 +00:00
Michel Roegl-Brunner
9e450ecbd1 Merge pull request #287 from community-scripts/feat/250_chose_version
feat: Add alpine variant support for LXC scripts
2025-11-07 14:55:21 +01:00
Michel Roegl-Brunner
cf3f9a5479 feat: Add version toggle in TextViewer for default/alpine variants
- Add version selection toggle (Default/Alpine) in TextViewer component
- Load both default and alpine versions of CT and install scripts
- Display correct script content based on selected version
- Pass script object to TextViewer to detect alpine variants
- Show toggle buttons only when alpine variant exists
2025-11-07 14:51:12 +01:00
Michel Roegl-Brunner
6ad18e185e feat: Add alpine variant support for LXC scripts
- Download alpine install scripts (alpine-{slug}-install.sh) when alpine variant exists
- Add ScriptVersionModal component for version selection (default/alpine)
- Update ScriptDetailModal to show version selection before server selection
- Update script execution to use selected version type
- Support downloading both default and alpine variants of scripts
2025-11-07 14:44:34 +01:00
Michel Roegl-Brunner
42fbdc87da Merge pull request #286 from community-scripts/feat/252_sessions
feat: Add persistent session authentication with configurable duration
2025-11-07 13:35:26 +01:00
Michel Roegl-Brunner
8e2286d847 feat: Add persistent session authentication with configurable duration
- Implement persistent session authentication with httpOnly cookies
- Add configurable session duration (1-365 days) in settings
- Add session expiration display in settings modal
- Add logout button next to theme toggle
- Enhance token verification to return expiration time
- Add retry logic for failed auth checks
- Add comprehensive authentication documentation to help modal
- Improve session restoration on page load
2025-11-07 13:31:16 +01:00
Michel Roegl-Brunner
73776ec6ac Merge pull request #285 from community-scripts/feat/allow_fdqn_and_ipv6_for_servers
feat: Add support for FQDN, IPv4, and IPv6 addresses in server settings
2025-11-07 13:20:15 +01:00
Michel Roegl-Brunner
596887d19c feat: Add support for FQDN, IPv4, and IPv6 addresses in server settings
- Replace IPv4-only validation with comprehensive address validation
- Support IPv4 addresses (e.g., 192.168.1.1)
- Support IPv6 addresses including compressed format (e.g., ::1, 2001:db8::1)
- Support FQDN/hostnames (e.g., server.example.com, localhost)
- Update UI label from 'IP Address' to 'Host/IP Address'
- Update placeholder text with examples for all supported formats
- Update error messages to reflect new validation capabilities
2025-11-07 13:17:42 +01:00
Michel Roegl-Brunner
0676772992 Merge pull request #283 from community-scripts/feat/delete_downloaded_scripts
Add delete script functionality
2025-11-07 13:12:38 +01:00
Michel Roegl-Brunner
72a9ea52b0 Merge pull request #284 from community-scripts/bump/tools.func
Bump tools.func - update helper functions and package management utilities
2025-11-07 13:12:27 +01:00
Michel Roegl-Brunner
826ee26cdf Bump tools.func - update helper functions and package management utilities 2025-11-07 13:11:47 +01:00
Michel Roegl-Brunner
82d14f1fb1 Add delete script functionality
- Add deleteScript method to ScriptDownloaderService (both .ts and .js)
- Add deleteScript API endpoint to scripts router
- Add delete button to ScriptDetailModal with confirmation modal
- Use ConfirmationModal component instead of plain window.confirm
- Delete button only shows when script files exist locally
- Includes proper error handling and success/error messages
2025-11-07 13:10:37 +01:00
Michel Roegl-Brunner
220b610466 Merge pull request #282 from community-scripts/fix/273
Fix x button vertical centering in filter scripts search bar
2025-11-07 13:04:45 +01:00
Michel Roegl-Brunner
e460a05f91 Fix x button vertical centering in filter scripts search bar
- Removed size='icon' constraint that limited button height
- Changed positioning to use inset-y-0 with h-full to match input field height
- Added flex items-center justify-center for proper icon centering
- Fixes hover background height mismatch between light and dark themes
2025-11-07 13:03:22 +01:00
Michel Roegl-Brunner
21f723bff6 Merge pull request #281 from community-scripts/fix/270
Add minimize buttons to FilterBar and Newest Scripts sections
2025-11-07 13:00:20 +01:00
Michel Roegl-Brunner
45ba67c827 Add minimize buttons to FilterBar and Newest Scripts sections
- Add minimize/collapse functionality to FilterBar component
- Add minimize/collapse functionality to Newest Scripts section
- Hide Newest Scripts section when user is searching, filtering, or viewing a category
- Both sections can now be minimized to save screen space
2025-11-07 12:58:22 +01:00
Michel Roegl-Brunner
61904f2936 Merge pull request #280 from community-scripts/fix/269
Add Interface Port display to script detail modal header
2025-11-07 12:55:47 +01:00
Michel Roegl-Brunner
237aee9c46 Add Interface Port display to script detail modal header
- Display port in header next to script name for better visibility
- Position port close to name/logo section
- Keep port display in Basic Information section as well
- Style port with badge-like appearance for prominence
2025-11-07 12:53:54 +01:00
Michel Roegl-Brunner
6bd402abea Merge pull request #279 from community-scripts/fix/262
Fix stale LXC entries and improve orphaned script cleanup
2025-11-07 12:50:35 +01:00
Michel Roegl-Brunner
bd3ca74175 Fix stale LXC entries and improve orphaned script cleanup
- Improved cleanupOrphanedScripts to use pct list for more reliable container verification
- Added batch processing by server for better efficiency
- Added double-check with config file existence before deletion
- Added manual cleanup button in Installed Scripts tab for on-demand cleanup
- Improved error handling and logging throughout cleanup process
- Fixes issue where deleted containers (like Planka) were still showing in the UI
2025-11-07 12:47:19 +01:00
Michel Roegl-Brunner
9fc61bb416 Merge pull request #278 from community-scripts/fix/253
Fix: Include 'New' scripts in Download All Filtered when no filters are active
2025-11-07 12:44:32 +01:00
Michel Roegl-Brunner
75d52c771f Fix: Include 'New' scripts in Download All Filtered when no filters are active
- Modified handleDownloadAllFiltered to combine filteredScripts and newestScripts when no filters are active
- Ensures all scripts including those in the 'Newest Scripts' carousel are downloaded
- Maintains existing behavior when filters are active
- Fixes issue where 'New' scripts were excluded from downloads
2025-11-07 12:42:57 +01:00
Michel Roegl-Brunner
c868b008c5 Merge pull request #277 from community-scripts/fix/251
Fix Install button not appearing for VM and tools scripts
2025-11-07 12:41:34 +01:00
Michel Roegl-Brunner
7f1ef91db9 Fix vitest dependency version mismatch
- Downgrade @vitest/coverage-v8 from ^4.0.4 to ^3.2.4 to match vitest@^3.2.4
- Resolves npm ci dependency conflict error
2025-11-07 12:39:46 +01:00
Michel Roegl-Brunner
2ee5f73117 Fix Install button not appearing for VM and tools scripts
- Update checkScriptExists to preserve subdirectory structure for tools and VM scripts
- Set ctExists=true for tools and VM scripts when files exist (not just CT scripts)
- Add missing compareScriptContent method to JavaScript version
- Remove all VW script references as they don't exist
- Fixes issue where Install button only showed Load Script button after downloading VM/tools scripts
2025-11-07 12:36:43 +01:00
dependabot[bot]
881456e74e build(deps): Bump @prisma/client from 6.17.1 to 6.18.0 (#256)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.17.1 to 6.18.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.18.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 6.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 13:16:36 -07:00
dependabot[bot]
650afd5872 build(deps-dev): Bump @vitest/coverage-v8 from 4.0.3 to 4.0.4 (#255)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.4/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 13:16:27 -07:00
dependabot[bot]
f0dc393358 build(deps-dev): Bump @vitest/coverage-v8 from 3.2.4 to 4.0.3 (#243)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 3.2.4 to 4.0.3.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.3/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 22:11:55 -07:00
Michel Roegl-Brunner
db72fea774 Merge pull request #241 from community-scripts/dependabot/npm_and_yarn/lucide-react-0.548.0 2025-10-24 23:48:47 +02:00
Michel Roegl-Brunner
24896f9c0d Merge pull request #245 from community-scripts/dependabot/npm_and_yarn/tailwindcss/postcss-4.1.16 2025-10-24 23:48:28 +02:00
Michel Roegl-Brunner
bf434e3b39 Merge pull request #242 from community-scripts/dependabot/npm_and_yarn/vitejs/plugin-react-5.1.0 2025-10-24 23:48:15 +02:00
github-actions[bot]
7699858a35 chore: add VERSION v0.4.9 (#248)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-24 20:39:11 +00:00
Michel Roegl-Brunner
68b6fc81bd Merge pull request #247 from community-scripts/fix/script-card-click-issues
Fix: Autosync disable functionality and multiple instance issues
2025-10-24 22:36:37 +02:00
Michel Roegl-Brunner
16947cedf5 Remove debug logs from autosync service
- Remove excessive debug logging from scheduleAutoSync and stopAutoSync
- Keep essential logging for troubleshooting
- Clean up console output for production use
2025-10-24 22:30:26 +02:00
Michel Roegl-Brunner
eca0cb57f9 Fix TypeScript linting errors in autoSyncService
- Add JSDoc type definitions for settings object
- Fix appriseUrls undefined checks
- Fix null assignment for lastAutoSyncError
- Add proper type annotations for error fields

All TypeScript linting errors are now resolved.
2025-10-24 22:30:15 +02:00
Michel Roegl-Brunner
011cbd23b2 Fix autosync continuing to run after being disabled
- Add destroy() method to properly stop cron jobs
- Add additional safety checks in cron job execution
- Add debugging logs to track cron job lifecycle
- Ensure isRunning is set to false even when no cron job exists
- Add null check for cronJob before execution

This should prevent autosync from continuing to run after being disabled.
2025-10-24 22:29:37 +02:00
Michel Roegl-Brunner
19e18b4ebf Fix multiple autosync instances running simultaneously
- Add global lock to prevent multiple autosync instances from running
- Add initialization check to prevent multiple service creation
- Add global lock checks in cron job execution
- Prevent multiple notifications from being sent
- Fix TypeScript errors with error field types

This fixes the issue where 6+ autosync instances were running simultaneously,
causing multiple notifications and rate limit issues.
2025-10-24 22:27:40 +02:00
Michel Roegl-Brunner
ffef6313d4 Add error reporting for GitHub rate limit errors
- Add specific error handling for GitHub API rate limit (403) errors
- Create RateLimitError with proper error name for identification
- Store last sync error and error time in settings for UI display
- Add error status display in autosync settings modal
- Show user-friendly error messages with GitHub token suggestion
- Clear error status on successful sync
- Update both GitHubJsonService and GitHubService with rate limit error handling

This provides better user feedback when GitHub API rate limits are exceeded
and guides users to set up a GitHub token for higher rate limits.
2025-10-24 22:23:59 +02:00
Michel Roegl-Brunner
7b4daf8754 Fix autosync continuing to run after being disabled
- Add defensive check in cron job execution to stop if autosync is disabled
- Ensure isRunning flag is set to false when stopping autosync
- Add logging to show when autosync is disabled and not scheduling
- Fix both API route and TRPC router to properly stop service
- Prevent multiple cron jobs from running simultaneously

This fixes the issue where autosync would continue running even after
being disabled in the GUI, causing rate limit errors and unwanted syncs.
2025-10-24 22:20:13 +02:00
Michel Roegl-Brunner
fdeda6c77a Add GitHub token authentication to sync services
- Add GitHub token authentication to GitHubJsonService for API calls
- Add GitHub token authentication to GitHubService for API calls
- Update fetchFromGitHub methods to use GITHUB_TOKEN from .env
- Update downloadJsonFile methods to use GitHub token for raw file downloads
- Add proper error handling for rate limit exceeded (403) errors
- Add console logging to show when token is/isn't being used
- Improve error messages to suggest setting GITHUB_TOKEN for higher rate limits

This ensures that when a GitHub token is specified in .env, it will be used
for all GitHub API calls during sync operations, providing higher rate limits
and better reliability.
2025-10-24 22:16:31 +02:00
Michel Roegl-Brunner
5acaf144fb Fix autosync toggle disable functionality
- Fix service instance management to use global instance for stopping autosync
- Add automatic saving when toggle is changed (no manual save required)
- Fix validation issue where custom sync type without cron expression caused 400 error
- Add comprehensive debugging and error handling
- Ensure .env file is properly updated with AUTO_SYNC_ENABLED value
- Improve service lifecycle management with proper state cleanup
- Add fallback logic for invalid sync interval configurations

Resolves issue where disabling autosync in GUI didn't update .env file or stop service
2025-10-24 22:15:24 +02:00
Michel Roegl-Brunner
926032e83b Fix auto-sync service not stopping when disabled
- Use global auto-sync service instance instead of creating new instances
- This ensures stopAutoSync() is called on the actual running service
- Fix .env file parsing to handle comments and empty lines properly
- Update both TRPC and REST API endpoints to use global instance
- Auto-sync service will now properly stop when disabled in settings
2025-10-24 22:09:16 +02:00
Michel Roegl-Brunner
8fc9b27f55 Fix missing access import in scriptDownloader.js
- Add missing 'access' import from 'fs/promises'
- This was causing checkScriptExists method to fail with 'access is not defined' error
- Now properly detects when script files exist locally
- Fixes issue where UI shows 'Load Script' instead of 'Install' button for downloaded scripts
2025-10-24 22:07:46 +02:00
Michel Roegl-Brunner
9a8cff3227 Add missing checkScriptExists method to scriptDownloader.js
- Add checkScriptExists method that was missing from JavaScript implementation
- This method is required by the checkScriptFiles API endpoint
- Method checks if CT script and install script files exist locally
- Returns ctExists, installExists, and files array
- Fixes issue where UI shows 'Load Script' instead of 'Install' button for downloaded scripts
2025-10-24 22:06:34 +02:00
Michel Roegl-Brunner
e40bd1f6a3 Fix CT script source line replacement
- Implement modifyScriptContent method to replace GitHub source line with local source
- Replace 'source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)'
  with 'SCRIPT_DIR="." \nsource "/../core/build.func"'
- This ensures CT scripts use local build.func instead of downloading from GitHub
- Applied to all CT scripts during download process
- Tested with 2fauth script - replacement works correctly
2025-10-24 22:04:23 +02:00
Michel Roegl-Brunner
6c982050da Fix notification to show script names grouped by category
- Store full script objects instead of just names in results.newScripts and results.updatedScripts
- This allows groupScriptsByCategory to properly group scripts by their categories
- Notification will now show actual script names grouped by category instead of 'undefined synced, undefined up to date'
- Script objects contain name, slug, and categories fields needed for proper grouping
2025-10-24 22:02:56 +02:00
Michel Roegl-Brunner
b40f5b788c Fix script downloader placeholder files issue
- Delete stub scriptDownloader.js that contained placeholder implementation
- Implement real JavaScript script downloader with GitHub fetch functionality
- Fix incremental JSON sync to only process newly synced files
- Add proper error handling and file structure management
- Support all script types (ct/, tools/, vm/, vw/) with directory preservation
- Download install scripts for CT scripts
- Re-enable auto-sync service to use real implementation

Scripts now download real content from GitHub instead of placeholders.
2025-10-24 21:58:35 +02:00
dependabot[bot]
962a3b9908 build(deps-dev): Bump @tailwindcss/postcss from 4.1.15 to 4.1.16
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.1.15 to 4.1.16.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.16/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 19:38:29 +00:00
dependabot[bot]
a1fe386efd build(deps-dev): Bump @vitejs/plugin-react from 5.0.4 to 5.1.0
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.0.4 to 5.1.0.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.0/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 19:37:55 +00:00
dependabot[bot]
aa76e17e25 build(deps): Bump lucide-react from 0.546.0 to 0.548.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.546.0 to 0.548.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.548.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.548.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 19:37:40 +00:00
Michel Roegl-Brunner
2c3fdf5544 Fix script card click issues and missing Install button
- Fix type signature mismatch in handleCardClick functions
  - Updated ScriptsGrid.tsx and DownloadedScriptsTab.tsx to accept ScriptCardType instead of { slug: string }
  - This resolves the modal not opening when clicking script cards

- Fix server-side import issues
  - Updated autoSyncService.js to import from githubJsonService.ts instead of .js
  - Fixed path aliases in githubJsonService.ts to use relative imports
  - Updated scripts.ts to import from TypeScript service files directly

- Fix missing Install button
  - Resolved scriptDownloaderService.checkScriptExists method not being available
  - Install button now appears when script files exist locally

- Remove debug logging
  - Cleaned up temporary console.log statements and debug UI elements

All script card interactions now work properly:
- Cards open detail modal when clicked
- Install button appears when appropriate
- Server-side API calls work correctly
2025-10-24 21:22:46 +02:00
github-actions[bot]
74dd29b87b chore: add VERSION v0.4.8 (#239)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-24 11:00:54 +00:00
Michel Roegl-Brunner
8a07fb781c Remove sync upstream JSONs step from publish_release.yml
Removed the sync upstream JSONs step from the workflow.
2025-10-24 12:59:53 +02:00
Michel Roegl-Brunner
79f17236db Merge pull request #238 from community-scripts/feat/auto_sync_lxc
feat: Add comprehensive auto-sync functionality
2025-10-24 12:58:56 +02:00
Michel Roegl-Brunner
83ab60ec2d Merge pull request #233 from community-scripts/dependabot/npm_and_yarn/tailwindcss-4.1.16
build(deps-dev): Bump tailwindcss from 4.1.15 to 4.1.16
2025-10-24 12:57:25 +02:00
Michel Roegl-Brunner
cd2b00b704 fix: Resolve linter errors in autoSyncService.js
- Added @ts-ignore comment for scriptDownloaderService.initializeConfig() call
- Added explicit JSDoc type annotations for forEach callback parameters
- Fixed 'implicitly has an any type' errors for catId and scriptName parameters
- All linter errors resolved while maintaining functionality

The categorization feature is now fully functional with clean, type-safe code.
2025-10-24 12:57:08 +02:00
Michel Roegl-Brunner
7817ce3d8e feat: Add script categorization to auto-sync notifications
- Added loadCategories() method to load category definitions from metadata.json
- Added groupScriptsByCategory() method to group scripts by their categories
- Modified scriptDownloaderService to return full script objects instead of just names
- Updated notification format to show scripts grouped by category with proper formatting
- Scripts are now displayed as:
  **Category Name:**
  • Script Name 1
  • Script Name 2

This provides much better organization in notifications, making it easier to
see what types of scripts were downloaded or updated.
2025-10-24 12:53:07 +02:00
Michel Roegl-Brunner
82b2012f50 fix: Import Buffer explicitly for ES module compatibility
- Added import { Buffer } from 'buffer' to githubJsonService.js
- Fixed 'require is not defined' error when using Buffer.from() in ES modules
- Auto-sync now works correctly through both direct execution and web API

The Buffer global is not available in ES module context, so it needs to be
explicitly imported. This fixes the sync errors that were occurring when
the auto-sync service was called through the web interface.
2025-10-24 12:48:22 +02:00
Michel Roegl-Brunner
bb4eb2964b fix: Remove CommonJS require() calls in ES module context
- Fixed 'require is not defined' error in githubJsonService.js
- Imported utimesSync from fs module instead of using require('fs').utimesSync
- Auto-sync now works without ES module errors

The JSON sync now completes successfully without any require() errors.
2025-10-24 12:46:50 +02:00
Michel Roegl-Brunner
a6a02a15fe fix: Auto-sync file detection and script downloader initialization
- Fixed statSync import in githubJsonService.js
- Added proper initialization of scriptDownloaderService before use
- Fixed local file detection - now correctly finds 411 local files instead of 0
- Auto-sync now properly shows 'Files to sync: 0, Up-to-date: 404' instead of downloading all
- Added debugging output to track file detection process

The auto-sync now correctly detects existing files and only syncs what's actually new or changed.
2025-10-24 12:43:50 +02:00
Michel Roegl-Brunner
bf5b602fd1 fix: Custom cron input and auto-sync rescheduling
- Fixed custom cron input field to be properly editable with autoFocus
- Added helpful cron examples and better validation feedback
- Fixed cron validation to work with 5-field expressions (node-cron format)
- Added auto-sync rescheduling when settings are saved via API route
- Improved user experience with better error handling and examples

The custom cron input now works properly and auto-sync will reschedule
immediately when settings are saved, including custom cron expressions.
2025-10-24 12:41:24 +02:00
Michel Roegl-Brunner
4d12a51b05 fix: Auto-sync now only downloads truly new scripts
- Fixed isScriptDownloaded logic to check ALL script files before considering a script downloaded
- Modified auto-sync to filter and only process scripts that haven't been downloaded before
- Added proper logging to show how many new scripts were found vs total scripts
- Made isScriptDownloaded method public in TypeScript version

This ensures auto-sync only downloads scripts that are actually new,
not re-downloading existing scripts or processing unchanged content.
2025-10-24 12:33:00 +02:00
Michel Roegl-Brunner
e0bea6c6e0 feat: Add comprehensive auto-sync functionality
 New Features:
- Auto-sync service with configurable intervals (15min, 30min, 1hour, 6hours, 12hours, 24hours, custom cron)
- Automatic JSON file synchronization from GitHub repositories
- Auto-download new scripts when JSON files are updated
- Auto-update existing scripts when newer versions are available
- Apprise notification service integration for sync status updates
- Comprehensive error handling and logging

🔧 Technical Implementation:
- AutoSyncService: Core scheduling and execution logic
- GitHubJsonService: Handles JSON file synchronization from GitHub
- AppriseService: Sends notifications via multiple channels (Discord, Telegram, Email, Slack, etc.)
- ScriptDownloaderService: Manages automatic script downloads and updates
- Settings API: RESTful endpoints for auto-sync configuration
- UI Integration: Settings modal with auto-sync configuration options

📋 Configuration Options:
- Enable/disable auto-sync functionality
- Flexible scheduling (predefined intervals or custom cron expressions)
- Selective script processing (new downloads, updates, or both)
- Notification settings with multiple Apprise URL support
- Environment-based configuration with .env file persistence

🎯 Benefits:
- Keeps script repository automatically synchronized
- Reduces manual maintenance overhead
- Provides real-time notifications of sync status
- Supports multiple notification channels
- Configurable to match different deployment needs

This feature significantly enhances the automation capabilities of PVE Scripts Local,
making it a truly hands-off solution for script management.
2025-10-24 12:28:44 +02:00
dependabot[bot]
2f1b738164 build(deps-dev): Bump tailwindcss from 4.1.15 to 4.1.16
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.15 to 4.1.16.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.16/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 10:28:06 +00:00
Michel Roegl-Brunner
3639dd231a Merge pull request #234 from community-scripts/dependabot/npm_and_yarn/prisma-6.18.0
build(deps-dev): Bump prisma from 6.17.1 to 6.18.0
2025-10-24 12:26:46 +02:00
Michel Roegl-Brunner
86f55069e6 Merge pull request #237 from community-scripts/update/tools.func
fix: Container ID display not showing after whiptail input and update tools.func
2025-10-24 09:21:51 +02:00
Michel Roegl-Brunner
5faa7f3646 remove debian 2025-10-24 09:20:00 +02:00
Michel Roegl-Brunner
bc52256301 fix: Container ID display not showing after whiptail input
- Move echo statement outside whiptail output capture block
- Fix output redirection interference with Container ID display
- Ensure Container ID is properly displayed regardless of user input
- Consolidate duplicate echo statements into single display
2025-10-24 09:19:34 +02:00
Michel Roegl-Brunner
9e66cdd7ea Cleanup 2025-10-24 08:51:05 +02:00
dependabot[bot]
931c9cedf1 build(deps-dev): Bump prisma from 6.17.1 to 6.18.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.17.1 to 6.18.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.18.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 6.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-23 19:26:58 +00:00
Michel Roegl-Brunner
83a51db265 Merge pull request #230 from community-scripts/dependabot/npm_and_yarn/eslint-config-next-16.0.0 2025-10-23 13:39:24 +02:00
Michel Roegl-Brunner
1b09d1494e Merge pull request #231 from community-scripts/dependabot/npm_and_yarn/superjson-2.2.3 2025-10-23 13:38:56 +02:00
dependabot[bot]
02fe842995 build(deps): Bump superjson from 2.2.2 to 2.2.3
Bumps [superjson](https://github.com/blitz-js/superjson) from 2.2.2 to 2.2.3.
- [Release notes](https://github.com/blitz-js/superjson/releases)
- [Commits](https://github.com/blitz-js/superjson/commits)

---
updated-dependencies:
- dependency-name: superjson
  dependency-version: 2.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-22 19:27:06 +00:00
dependabot[bot]
a464c845c2 build(deps-dev): Bump eslint-config-next from 15.5.6 to 16.0.0
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 15.5.6 to 16.0.0.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.0/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-22 19:27:00 +00:00
Michel Roegl-Brunner
5a69f58770 Merge pull request #226 from community-scripts/dependabot/npm_and_yarn/types/node-24.9.1 2025-10-21 21:54:35 +02:00
dependabot[bot]
48c243f624 build(deps-dev): Bump @types/node from 24.9.0 to 24.9.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.9.0 to 24.9.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 19:28:07 +00:00
github-actions[bot]
7af0717b1b chore: add VERSION v0.4.7 (#225)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-21 14:34:32 +00:00
Michel Roegl-Brunner
9977d390ac fix update.sh and env.example 2025-10-21 16:30:56 +02:00
Michel Roegl-Brunner
ea5e801718 fix update.sh and env.example 2025-10-21 15:46:58 +02:00
Michel Roegl-Brunner
7d54481f75 fix update.sh and env.example 2025-10-21 15:41:50 +02:00
Michel Roegl-Brunner
fbc6a9362e fix update.sh and env.example 2025-10-21 15:34:11 +02:00
Michel Roegl-Brunner
6b534474c4 Fix update.sh 2025-10-21 14:34:51 +02:00
Michel Roegl-Brunner
5bfbaca732 Fix update.sh 2025-10-21 14:33:24 +02:00
github-actions[bot]
a3d0141950 chore: add VERSION v0.4.6 (#220)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-21 11:21:33 +00:00
Michel Roegl-Brunner
529cb92e3c Fix Update 2025-10-21 13:16:14 +02:00
Michel Roegl-Brunner
b175c709f0 Fix update 2025-10-21 11:00:12 +02:00
Michel Roegl-Brunner
bf908eef66 Merge pull request #215 from community-scripts/dependabot/npm_and_yarn/npm_and_yarn-fd296dbd23 2025-10-21 07:09:55 +02:00
dependabot[bot]
4adf052db4 build(deps-dev): Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.9 to 7.1.11
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 22:11:33 +00:00
dependabot[bot]
2158ea42ba build(deps-dev): Bump @tailwindcss/postcss from 4.1.14 to 4.1.15 (#214) 2025-10-20 23:16:58 +02:00
dependabot[bot]
5fc1205ca5 build(deps-dev): Bump tailwindcss from 4.1.14 to 4.1.15 (#213) 2025-10-20 23:16:45 +02:00
Michel Roegl-Brunner
d928058f97 Merge pull request #212 from community-scripts/dependabot/npm_and_yarn/types/node-24.9.0 2025-10-20 23:16:31 +02:00
dependabot[bot]
11e1704116 build(deps-dev): Bump jsdom from 27.0.0 to 27.0.1 (#211) 2025-10-20 23:16:19 +02:00
dependabot[bot]
26829ce355 build(deps-dev): Bump typescript-eslint from 8.46.1 to 8.46.2 (#210) 2025-10-20 23:16:06 +02:00
dependabot[bot]
deffba0969 build(deps-dev): Bump @types/node from 24.8.1 to 24.9.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.8.1 to 24.9.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 19:48:08 +00:00
65 changed files with 10703 additions and 3785 deletions

View File

@@ -26,4 +26,13 @@ AUTH_PASSWORD_HASH=
AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false
JWT_SECRET=
DATABASE_URL="file:./data/database.sqlite"
DATABASE_URL="file:/opt/ProxmoxVE-Local/data/settings.db"
AUTO_SYNC_ENABLED=false
SYNC_INTERVAL_TYPE=
SYNC_INTERVAL_PREDEFINED=
AUTO_DOWNLOAD_NEW=
AUTO_UPDATE_EXISTING=
NOTIFICATION_ENABLED=
APPRISE_URLS=
LAST_AUTO_SYNC=
SYNC_INTERVAL_CRON=

View File

@@ -46,35 +46,6 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "chore: add VERSION $version" --allow-empty
- name: Sync upstream JSONs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tmp_dir=$(mktemp -d)
api_url="https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
# Fetch file list (no subfolders)
curl -sSL -H "Authorization: token $GH_TOKEN" "$api_url" \
| jq -r '.[] | select(.type=="file") | .name' > "$tmp_dir/files.txt"
# Download each file
while IFS= read -r name; do
curl -sSL -H "Authorization: token $GH_TOKEN" \
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/frontend/public/json/$name" \
-o "$tmp_dir/$name"
done < "$tmp_dir/files.txt"
mkdir -p json
rsync -a --delete "$tmp_dir/" json/
# Stage and amend commit to include JSON updates (and VERSION)
git add json VERSION
if ! git diff --cached --quiet; then
git commit --amend --no-edit
fi
- name: Push changes
run: |
git push --force-with-lease --set-upstream origin "update-version-${{ steps.draft.outputs.tag_name }}"

3
.gitignore vendored
View File

@@ -37,6 +37,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
update.log
server.log
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env

View File

@@ -1 +1 @@
0.4.6
0.4.10

View File

@@ -1,44 +0,0 @@
{
"name": "Frigate",
"slug": "frigate",
"categories": [
15
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": false,
"privileged": true,
"interface_port": 5000,
"documentation": "https://docs.frigate.video/",
"website": "https://frigate.video/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/frigate.webp",
"config_path": "",
"description": "Frigate is an open source NVR built around real-time AI object detection. All processing is performed locally on your own hardware, and your camera feeds never leave your home.",
"install_methods": [
{
"type": "default",
"script": "ct/frigate.sh",
"resources": {
"cpu": 4,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "11"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Discussions (explore more advanced methods): `https://github.com/tteck/Proxmox/discussions/2711`",
"type": "info"
},
{
"text": "go2rtc Interface port:`1984`",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "13"
"version": "12"
}
},
{

View File

@@ -12,7 +12,7 @@
"documentation": "https://docs.openarchiver.com/",
"config_path": "/opt/openarchiver/.env",
"website": "https://openarchiver.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/OpenArchiver.webp",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/open-archiver.webp",
"description": "Open Archiver is a secure, self-hosted email archiving solution, and it's completely open source. Get an email archiver that enables full-text search across email and attachments. Create a permanent, searchable, and compliant mail archive from Google Workspace, Microsoft 35, and any IMAP server.",
"install_methods": [
{

View File

@@ -19,8 +19,8 @@
"type": "default",
"script": "ct/paperless-ai.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"cpu": 4,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "13"

View File

@@ -1,29 +1,164 @@
[
{
"name": "apache/cassandra",
"version": "5.0.6-tentative",
"date": "2025-10-21T11:42:35Z"
},
{
"name": "evcc-io/evcc",
"version": "0.209.3",
"date": "2025-10-21T10:53:07Z"
},
{
"name": "grafana/grafana",
"version": "v11.5.10",
"date": "2025-10-21T10:46:47Z"
},
{
"name": "zabbix/zabbix",
"version": "7.0.20rc1",
"date": "2025-10-21T09:14:14Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.115.3",
"date": "2025-10-14T14:40:17Z"
},
{
"name": "VictoriaMetrics/VictoriaMetrics",
"version": "pmm-6401-v1.128.0",
"date": "2025-10-21T08:30:52Z"
},
{
"name": "crowdsecurity/crowdsec",
"version": "v1.7.1",
"date": "2025-10-15T10:44:03Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-v1.24.0.ignore-embedding-failures-2",
"date": "2025-10-21T07:36:22Z"
},
{
"name": "Jackett/Jackett",
"version": "v0.24.166",
"date": "2025-10-21T05:54:33Z"
},
{
"name": "openobserve/openobserve",
"version": "v0.16.0-rc1",
"date": "2025-10-21T00:37:47Z"
},
{
"name": "jeedom/core",
"version": "4.4.20",
"date": "2025-10-21T00:27:05Z"
},
{
"name": "steveiliop56/tinyauth",
"version": "v4.0.1",
"date": "2025-10-15T16:53:55Z"
},
{
"name": "documenso/documenso",
"version": "v1.13.0",
"date": "2025-10-21T00:21:04Z"
},
{
"name": "goauthentik/authentik",
"version": "version/2025.10.0-rc2",
"date": "2025-10-21T00:19:36Z"
},
{
"name": "TwiN/gatus",
"version": "v5.27.1",
"date": "2025-10-21T00:01:36Z"
},
{
"name": "henrygd/beszel",
"version": "v0.14.1",
"date": "2025-10-20T22:10:56Z"
},
{
"name": "keycloak/keycloak",
"version": "26.4.1",
"date": "2025-10-16T07:21:53Z"
},
{
"name": "booklore-app/booklore",
"version": "v1.8.1",
"date": "2025-10-20T20:53:56Z"
},
{
"name": "coder/code-server",
"version": "v4.105.1",
"date": "2025-10-20T20:19:23Z"
},
{
"name": "pymedusa/Medusa",
"version": "v1.0.23",
"date": "2025-10-20T19:51:33Z"
},
{
"name": "chrisbenincasa/tunarr",
"version": "v0.23.0-alpha.16",
"date": "2025-10-20T19:29:20Z"
},
{
"name": "MediaBrowser/Emby.Releases",
"version": "4.9.1.80",
"date": "2025-09-30T20:25:16Z"
},
{
"name": "kyantech/Palmr",
"version": "v3.2.4-beta",
"date": "2025-10-20T17:58:55Z"
},
{
"name": "tailscale/tailscale",
"version": "v1.91.0-pre",
"date": "2025-10-20T16:18:51Z"
},
{
"name": "louislam/uptime-kuma",
"version": "2.0.1",
"date": "2025-10-20T16:11:12Z"
},
{
"name": "msgbyte/tianji",
"version": "v1.29.2",
"date": "2025-10-20T16:07:20Z"
},
{
"name": "chrisvel/tududi",
"version": "v0.84.1",
"date": "2025-10-20T15:32:24Z"
},
{
"name": "rclone/rclone",
"version": "v1.71.2",
"date": "2025-10-20T15:25:52Z"
},
{
"name": "Graylog2/graylog2-server",
"version": "7.0.0-rc.1",
"date": "2025-10-20T11:53:31Z"
},
{
"name": "Kareadita/Kavita",
"version": "v0.8.8",
"date": "2025-10-20T11:26:24Z"
},
{
"name": "wizarrrr/wizarr",
"version": "v2025.10.4",
"date": "2025-10-20T10:45:54Z"
},
{
"name": "apache/cassandra",
"version": "cassandra-4.0.19",
"date": "2025-10-20T09:08:49Z"
},
{
"name": "jupyter/notebook",
"version": "@jupyter-notebook/ui-components@7.5.0-beta.1",
"date": "2025-10-20T07:01:38Z"
},
{
"name": "Jackett/Jackett",
"version": "v0.24.159",
"date": "2025-10-20T05:53:23Z"
},
{
"name": "inventree/InvenTree",
"version": "1.0.7",
@@ -49,16 +184,6 @@
"version": "v1.0.0-beta27",
"date": "2025-10-20T00:38:13Z"
},
{
"name": "jeedom/core",
"version": "4.4.20",
"date": "2025-10-20T00:27:05Z"
},
{
"name": "steveiliop56/tinyauth",
"version": "v4.0.1",
"date": "2025-10-15T16:53:55Z"
},
{
"name": "seriousm4x/UpSnap",
"version": "5.2.3",
@@ -99,11 +224,6 @@
"version": "v2.14.5.4836",
"date": "2025-10-08T15:30:50Z"
},
{
"name": "henrygd/beszel",
"version": "v0.14.0",
"date": "2025-10-18T23:54:15Z"
},
{
"name": "BerriAI/litellm",
"version": "v1.78.5.rc.1",
@@ -124,11 +244,6 @@
"version": "v2025-10-18",
"date": "2025-10-18T20:35:54Z"
},
{
"name": "chrisvel/tududi",
"version": "v0.84",
"date": "2025-10-18T19:39:04Z"
},
{
"name": "moghtech/komodo",
"version": "v1.19.5",
@@ -154,21 +269,6 @@
"version": "v0.9.0",
"date": "2025-10-18T17:03:56Z"
},
{
"name": "booklore-app/booklore",
"version": "v1.8.0",
"date": "2025-10-18T16:22:25Z"
},
{
"name": "chrisbenincasa/tunarr",
"version": "v0.23.0-alpha.14",
"date": "2025-10-18T15:43:53Z"
},
{
"name": "msgbyte/tianji",
"version": "v1.29.1",
"date": "2025-10-18T13:14:21Z"
},
{
"name": "TasmoAdmin/TasmoAdmin",
"version": "v4.3.2",
@@ -199,11 +299,6 @@
"version": "v25.4",
"date": "2025-10-09T10:27:01Z"
},
{
"name": "TwiN/gatus",
"version": "v5.27.0",
"date": "2025-10-18T02:44:26Z"
},
{
"name": "9001/copyparty",
"version": "v1.19.17",
@@ -219,26 +314,11 @@
"version": "2025.10.3",
"date": "2025-10-17T21:15:07Z"
},
{
"name": "coder/code-server",
"version": "v4.105.0",
"date": "2025-10-17T19:55:55Z"
},
{
"name": "MediaBrowser/Emby.Releases",
"version": "4.9.1.80",
"date": "2025-09-30T20:25:16Z"
},
{
"name": "forgejo/forgejo",
"version": "v13.0.1",
"date": "2025-10-17T18:54:16Z"
},
{
"name": "keycloak/keycloak",
"version": "26.4.1",
"date": "2025-10-16T07:21:53Z"
},
{
"name": "grokability/snipe-it",
"version": "v8.3.4",
@@ -254,6 +334,11 @@
"version": "v2.40.1",
"date": "2025-10-17T13:42:04Z"
},
{
"name": "neo4j/neo4j",
"version": "5.26.14",
"date": "2025-10-17T12:38:22Z"
},
{
"name": "mattermost/mattermost",
"version": "server/public/v0.1.21",
@@ -369,11 +454,6 @@
"version": "v2.13.1",
"date": "2025-10-15T13:29:37Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-docker-alpine-3-22-v8",
"date": "2025-10-15T13:20:20Z"
},
{
"name": "TandoorRecipes/recipes",
"version": "2.3.3",
@@ -384,11 +464,6 @@
"version": "jenkins-2.528.1",
"date": "2025-10-15T12:51:20Z"
},
{
"name": "Graylog2/graylog2-server",
"version": "7.0.0-beta.5",
"date": "2025-10-15T11:43:16Z"
},
{
"name": "cockpit-project/cockpit",
"version": "349",
@@ -399,11 +474,6 @@
"version": "v0.14.1",
"date": "2024-08-29T22:32:51Z"
},
{
"name": "openobserve/openobserve",
"version": "v0.15.2",
"date": "2025-10-15T07:42:29Z"
},
{
"name": "wavelog/wavelog",
"version": "2.1.2",
@@ -419,21 +489,11 @@
"version": "2025.10.0",
"date": "2025-10-14T19:07:37Z"
},
{
"name": "evcc-io/evcc",
"version": "0.209.2",
"date": "2025-10-14T18:55:44Z"
},
{
"name": "crafty-controller/crafty-4",
"version": "v4.5.5",
"date": "2025-10-14T18:48:36Z"
},
{
"name": "tailscale/tailscale",
"version": "v1.88.4",
"date": "2025-10-14T17:57:52Z"
},
{
"name": "plankanban/planka",
"version": "planka-1.1.0",
@@ -449,11 +509,6 @@
"version": "v1.140.0",
"date": "2025-10-14T15:57:12Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.115.3",
"date": "2025-10-14T14:40:17Z"
},
{
"name": "rogerfar/rdt-client",
"version": "v2.0.119",
@@ -584,11 +639,6 @@
"version": "11.0.1",
"date": "2025-10-09T12:34:15Z"
},
{
"name": "documenso/documenso",
"version": "v1.12.10",
"date": "2025-10-09T04:32:35Z"
},
{
"name": "rabbitmq/rabbitmq-server",
"version": "v4.1.4",
@@ -639,11 +689,6 @@
"version": "v0.15.1",
"date": "2025-10-07T20:30:56Z"
},
{
"name": "VictoriaMetrics/VictoriaMetrics",
"version": "pmm-6401-v1.127.0",
"date": "2025-10-07T14:31:32Z"
},
{
"name": "thecfu/scraparr",
"version": "v2.2.5",
@@ -714,11 +759,6 @@
"version": "8.2.2",
"date": "2025-10-03T06:22:38Z"
},
{
"name": "kyantech/Palmr",
"version": "v3.2.3-beta",
"date": "2025-10-02T13:48:14Z"
},
{
"name": "actualbudget/actual",
"version": "v25.10.0",
@@ -739,11 +779,6 @@
"version": "v5.41.4",
"date": "2025-09-30T22:26:11Z"
},
{
"name": "zabbix/zabbix",
"version": "7.4.3",
"date": "2025-09-30T21:49:53Z"
},
{
"name": "mongodb/mongo",
"version": "r8.2.1",
@@ -764,21 +799,11 @@
"version": "v1.7.4",
"date": "2025-09-30T13:34:30Z"
},
{
"name": "neo4j/neo4j",
"version": "4.4.46",
"date": "2025-09-30T13:21:24Z"
},
{
"name": "thomiceli/opengist",
"version": "v1.11.1",
"date": "2025-09-30T00:24:16Z"
},
{
"name": "goauthentik/authentik",
"version": "version/2025.8.4",
"date": "2025-09-30T00:03:11Z"
},
{
"name": "influxdata/telegraf",
"version": "v1.36.2",
@@ -849,11 +874,6 @@
"version": "1.2.39",
"date": "2025-09-25T15:57:02Z"
},
{
"name": "rclone/rclone",
"version": "v1.71.1",
"date": "2025-09-24T16:32:16Z"
},
{
"name": "alexta69/metube",
"version": "2025.09.24",
@@ -869,11 +889,6 @@
"version": "v2.0.10",
"date": "2025-09-24T08:33:37Z"
},
{
"name": "grafana/grafana",
"version": "v12.2.0",
"date": "2025-09-23T23:47:02Z"
},
{
"name": "postgres/postgres",
"version": "REL_18_0",
@@ -1014,11 +1029,6 @@
"version": "0.6",
"date": "2025-09-05T06:05:04Z"
},
{
"name": "louislam/uptime-kuma",
"version": "2.0.0-beta.2-temp",
"date": "2025-03-28T08:45:58Z"
},
{
"name": "healthchecks/healthchecks",
"version": "v3.11.2",
@@ -1224,11 +1234,6 @@
"version": "250707-d28b3101e",
"date": "2025-07-07T15:15:21Z"
},
{
"name": "Kareadita/Kavita",
"version": "v0.8.7",
"date": "2025-07-05T20:08:58Z"
},
{
"name": "qbittorrent/qBittorrent",
"version": "release-5.1.2",
@@ -1479,11 +1484,6 @@
"version": "v0.7.3",
"date": "2024-12-15T10:18:06Z"
},
{
"name": "pymedusa/Medusa",
"version": "v1.0.22",
"date": "2024-12-13T12:22:19Z"
},
{
"name": "phpipam/phpipam",
"version": "v1.7.3",

900
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.17.1",
"@prisma/client": "^6.18.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
@@ -36,12 +36,16 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.9",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-validator": "^1.2.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"lucide-react": "^0.548.0",
"next": "^15.5.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -51,36 +55,37 @@
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.1",
"superjson": "^2.2.3",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.8.1",
"@types/node": "^24.9.1",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-next": "^15.5.6",
"jsdom": "^27.0.0",
"eslint-config-next": "^16.0.0",
"jsdom": "^27.0.1",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.17.1",
"tailwindcss": "^4.1.14",
"prisma": "^6.18.0",
"tailwindcss": "^4.1.16",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.1",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"
},
"ct3aMetadata": {

View File

@@ -439,17 +439,14 @@ advanced_settings() {
exit_script
fi
done
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
if [ -z "$CT_ID" ]; then
CT_ID="$NEXTID"
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
exit_script
fi
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
while true; do
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"documentation": "https://docs.bunkerweb.io/latest/",
"website": "https://www.bunkerweb.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/bunkerweb.webp",
"config_path": "/opt/bunkerweb/variables.env",
"config_path": "/etc/bunkerweb/variables.env",
"description": "BunkerWeb is a security-focused web server that enhances web application protection. It guards against common web vulnerabilities like SQL injection, XSS, and CSRF. It features simple setup and configuration using a YAML file, customizable security rules, and provides detailed logs for traffic monitoring and threat detection.",
"install_methods": [
{

48
scripts/json/execute.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "PVE LXC Execute Command",
"slug": "lxc-execute",
"categories": [
1
],
"date_created": "2025-09-18",
"type": "pve",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "",
"description": "This script allows administrators to execute a custom command inside one or multiple LXC containers on a Proxmox VE node. Containers can be selectively excluded via an interactive checklist. If a container is stopped, the script will automatically start it, run the command, and then shut it down again. Only Debian and Ubuntu based containers are supported.",
"install_methods": [
{
"type": "default",
"script": "tools/pve/execute.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within the Proxmox shell.",
"type": "info"
},
{
"text": "Non-Debian/Ubuntu containers will be skipped automatically.",
"type": "info"
},
{
"text": "Stopped containers will be started temporarily to run the command, then shut down again.",
"type": "warning"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 10,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -12,7 +12,7 @@
"documentation": "https://github.com/HydroshieldMKII/Guardian/blob/main/README.md",
"config_path": "/opt/guardian/.env",
"website": "https://github.com/HydroshieldMKII/Guardian",
"logo": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/guardian-plex.webp",
"description": "Guardian is a lightweight companion app for Plex that lets you monitor, approve or block devices in real time. It helps you enforce per-user or global policies, stop unwanted sessions automatically and grant temporary access - all through a simple web interface.",
"install_methods": [
{

View File

@@ -21,7 +21,7 @@
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"hdd": 16,
"os": "ubuntu",
"version": "24.04"
}

40
scripts/json/jotty.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "jotty",
"slug": "jotty",
"categories": [
12
],
"date_created": "2025-10-21",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://github.com/fccview/jotty/blob/main/README.md",
"website": "https://github.com/fccview/jotty",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/jotty.webp",
"config_path": "/opt/jotty/.env",
"description": "A simple, self-hosted app for your checklists and notes. Tired of bloated, cloud-based to-do apps? jotty is a lightweight alternative for managing your personal checklists and notes. It's built with Next.js 14, is easy to deploy, and keeps all your data on your own server.",
"install_methods": [
{
"type": "default",
"script": "ct/jotty.sh",
"resources": {
"cpu": 2,
"ram": 3072,
"hdd": 6,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "jotty was previously named rwMarkable",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -1,186 +1,186 @@
{
"categories": [
{
"name": "Proxmox & Virtualization",
"id": 1,
"sort_order": 1.0,
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
"icon": "server"
},
{
"name": "Operating Systems",
"id": 2,
"sort_order": 2.0,
"description": "Scripts for deploying and managing various operating systems.",
"icon": "monitor"
},
{
"name": "Containers & Docker",
"id": 3,
"sort_order": 3.0,
"description": "Solutions for containerization using Docker and related technologies.",
"icon": "box"
},
{
"name": "Network & Firewall",
"id": 4,
"sort_order": 4.0,
"description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
]
}
"categories": [
{
"name": "Proxmox & Virtualization",
"id": 1,
"sort_order": 1.0,
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
"icon": "server"
},
{
"name": "Operating Systems",
"id": 2,
"sort_order": 2.0,
"description": "Scripts for deploying and managing various operating systems.",
"icon": "monitor"
},
{
"name": "Containers & Docker",
"id": 3,
"sort_order": 3.0,
"description": "Solutions for containerization using Docker and related technologies.",
"icon": "box"
},
{
"name": "Network & Firewall",
"id": 4,
"sort_order": 4.0,
"description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "13"
"version": "12"
}
},
{

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 6,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -0,0 +1,40 @@
{
"name": "Open-Archiver",
"slug": "open-archiver",
"categories": [
7
],
"date_created": "2025-10-18",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://docs.openarchiver.com/",
"config_path": "/opt/openarchiver/.env",
"website": "https://openarchiver.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/open-archiver.webp",
"description": "Open Archiver is a secure, self-hosted email archiving solution, and it's completely open source. Get an email archiver that enables full-text search across email and attachments. Create a permanent, searchable, and compliant mail archive from Google Workspace, Microsoft 35, and any IMAP server.",
"install_methods": [
{
"type": "default",
"script": "ct/open-archiver.sh",
"resources": {
"cpu": 2,
"ram": 3072,
"hdd": 8,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Data directory is: `/opt/openarchiver-data`. If you have a lot of email, you might consider mounting external storage to this directory.",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 8192,
"hdd": 25,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -19,8 +19,8 @@
"type": "default",
"script": "ct/paperless-ai.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"cpu": 4,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "13"

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 512,
"hdd": 2,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 5,
"os": "Debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -8,6 +8,11 @@ import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config();
// Fallback minimal global error handlers for Node runtime (avoid TS import)
function registerGlobalErrorHandlers() {
if (registerGlobalErrorHandlers._registered) return;
@@ -976,5 +981,11 @@ app.prepare().then(() => {
.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
// Initialize auto-sync service
initializeAutoSync();
// Setup graceful shutdown handlers
setupGracefulShutdown();
});
});

View File

@@ -1,11 +1,12 @@
'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
username: string | null;
isLoading: boolean;
expirationTime: number | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
@@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [expirationTime, setExpirationTime] = useState<number | null>(null);
const checkAuth = async () => {
const checkAuthInternal = async (retryCount = 0) => {
try {
// First check if setup is completed
const setupResponse = await fetch('/api/settings/auth-credentials');
@@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) {
if (!setupData.setupCompleted || !setupData.enabled) {
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
setIsLoading(false);
return;
}
}
// Only verify authentication if setup is completed and auth is enabled
const response = await fetch('/api/auth/verify');
const response = await fetch('/api/auth/verify', {
credentials: 'include', // Ensure cookies are sent
});
if (response.ok) {
const data = await response.json() as { username: string };
const data = await response.json() as {
username: string;
expirationTime?: number | null;
timeUntilExpiration?: number | null;
};
setIsAuthenticated(true);
setUsername(data.username);
setExpirationTime(data.expirationTime ?? null);
} else {
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
// Retry logic for failed auth checks (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
void checkAuthInternal(retryCount + 1);
}, 500);
return;
}
}
} catch (error) {
console.error('Error checking auth:', error);
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
// Retry logic for network errors (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
void checkAuthInternal(retryCount + 1);
}, 500);
return;
}
} finally {
setIsLoading(false);
}
};
const checkAuth = useCallback(() => {
return checkAuthInternal(0);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
@@ -65,12 +97,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
credentials: 'include', // Ensure cookies are received
});
if (response.ok) {
const data = await response.json() as { username: string };
setIsAuthenticated(true);
setUsername(data.username);
// Check auth again to get expiration time
await checkAuth();
return true;
} else {
const errorData = await response.json();
@@ -88,11 +124,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
};
useEffect(() => {
void checkAuth();
}, []);
}, [checkAuth]);
return (
<AuthContext.Provider
@@ -100,6 +137,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
isAuthenticated,
username,
isLoading,
expirationTime,
login,
logout,
checkAuth,

View File

@@ -356,7 +356,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}
}, [selectedCategory]);
const handleCardClick = (scriptCard: { slug: string }) => {
const handleCardClick = (scriptCard: ScriptCardType) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);

View File

@@ -41,6 +41,7 @@ export function FilterBar({
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
@@ -98,44 +99,17 @@ export function FilterBar({
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<Button
onClick={() => updateFilters({ searchQuery: "" })}
onClick={() => setIsMinimized(!isMinimized)}
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isMinimized ? "Expand filters" : "Minimize filters"}
>
<svg
className="h-5 w-5"
className={`h-4 w-4 transition-transform ${isMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -144,16 +118,68 @@ export function FilterBar({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
)}
</div>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Filter Content - Conditionally rendered based on minimized state */}
{!isMinimized && !isLoadingFilters && (
<>
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
)}
</div>
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -431,6 +457,8 @@ export function FilterBar({
</Button>
)}
</div>
</>
)}
{/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && (

View File

@@ -7,6 +7,8 @@ import { Toggle } from './ui/toggle';
import { ContextualHelpIcon } from './ContextualHelpIcon';
import { useTheme } from './ThemeProvider';
import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider';
interface GeneralSettingsModalProps {
isOpen: boolean;
@@ -16,7 +18,9 @@ interface GeneralSettingsModalProps {
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
const { theme, setTheme } = useTheme();
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null);
@@ -33,6 +37,22 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [sessionDurationDays, setSessionDurationDays] = useState(7);
// Auto-sync state
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
const [syncIntervalType, setSyncIntervalType] = useState<'predefined' | 'custom'>('predefined');
const [syncIntervalPredefined, setSyncIntervalPredefined] = useState('1hour');
const [syncIntervalCron, setSyncIntervalCron] = useState('');
const [autoDownloadNew, setAutoDownloadNew] = useState(false);
const [autoUpdateExisting, setAutoUpdateExisting] = useState(false);
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [appriseUrls, setAppriseUrls] = useState<string[]>([]);
const [appriseUrlsText, setAppriseUrlsText] = useState('');
const [lastAutoSync, setLastAutoSync] = useState('');
const [lastAutoSyncError, setLastAutoSyncError] = useState<string | null>(null);
const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState<string | null>(null);
const [cronValidationError, setCronValidationError] = useState('');
// Load existing settings when modal opens
useEffect(() => {
@@ -42,6 +62,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
void loadSavedFilters();
void loadAuthCredentials();
void loadColorCodingSetting();
void loadAutoSyncSettings();
}
}, [isOpen]);
@@ -197,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try {
const response = await fetch('/api/settings/auth-credentials');
if (response.ok) {
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean; sessionDurationDays?: number };
setAuthUsername(data.username ?? '');
setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false);
setSessionDurationDays(data.sessionDurationDays ?? 7);
}
} catch (error) {
console.error('Error loading auth credentials:', error);
@@ -210,6 +232,64 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
}
};
// Format expiration time display
const formatExpirationTime = (expTime: number | null): string => {
if (!expTime) return 'No active session';
const now = Date.now();
const timeUntilExpiration = expTime - now;
if (timeUntilExpiration <= 0) {
return 'Session expired';
}
const days = Math.floor(timeUntilExpiration / (1000 * 60 * 60 * 24));
const hours = Math.floor((timeUntilExpiration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((timeUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
const parts: string[] = [];
if (days > 0) {
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
}
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
}
if (minutes > 0 && days === 0) {
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
}
if (parts.length === 0) {
return 'Less than a minute';
}
return parts.join(', ');
};
// Update expiration display periodically
useEffect(() => {
const updateExpirationDisplay = () => {
if (expirationTime) {
setSessionExpirationDisplay(formatExpirationTime(expirationTime));
} else {
setSessionExpirationDisplay('');
}
};
updateExpirationDisplay();
// Update every minute
const interval = setInterval(updateExpirationDisplay, 60000);
return () => clearInterval(interval);
}, [expirationTime]);
// Refresh auth when tab changes to auth tab
useEffect(() => {
if (activeTab === 'auth' && isOpen) {
void checkAuth();
}
}, [activeTab, isOpen, checkAuth]);
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' });
@@ -248,6 +328,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
}
};
const saveSessionDuration = async (days: number) => {
if (days < 1 || days > 365) {
setMessage({ type: 'error', text: 'Session duration must be between 1 and 365 days' });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionDurationDays: days }),
});
if (response.ok) {
setMessage({ type: 'success', text: `Session duration updated to ${days} days` });
setSessionDurationDays(days);
setTimeout(() => setMessage(null), 3000);
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update session duration' });
setTimeout(() => setMessage(null), 3000);
}
} catch {
setMessage({ type: 'error', text: 'Failed to update session duration' });
setTimeout(() => setMessage(null), 3000);
} finally {
setAuthLoading(false);
}
};
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
@@ -278,6 +393,140 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
}
};
// Auto-sync functions
const loadAutoSyncSettings = async () => {
try {
const response = await fetch('/api/settings/auto-sync');
if (response.ok) {
const data = await response.json() as { settings: any };
const settings = data.settings;
if (settings) {
setAutoSyncEnabled(settings.autoSyncEnabled ?? false);
setSyncIntervalType(settings.syncIntervalType ?? 'predefined');
setSyncIntervalPredefined(settings.syncIntervalPredefined ?? '1hour');
setSyncIntervalCron(settings.syncIntervalCron ?? '');
setAutoDownloadNew(settings.autoDownloadNew ?? false);
setAutoUpdateExisting(settings.autoUpdateExisting ?? false);
setNotificationEnabled(settings.notificationEnabled ?? false);
setAppriseUrls(settings.appriseUrls ?? []);
setAppriseUrlsText((settings.appriseUrls ?? []).join('\n'));
setLastAutoSync(settings.lastAutoSync ?? '');
setLastAutoSyncError(settings.lastAutoSyncError ?? null);
setLastAutoSyncErrorTime(settings.lastAutoSyncErrorTime ?? null);
}
}
} catch (error) {
console.error('Error loading auto-sync settings:', error);
}
};
const saveAutoSyncSettings = async () => {
setIsSaving(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autoSyncEnabled,
syncIntervalType,
syncIntervalPredefined,
syncIntervalCron,
autoDownloadNew,
autoUpdateExisting,
notificationEnabled,
appriseUrls: appriseUrls
})
});
if (response.ok) {
setMessage({ type: 'success', text: 'Auto-sync settings saved successfully!' });
setTimeout(() => setMessage(null), 3000);
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save auto-sync settings' });
}
} catch (error) {
console.error('Error saving auto-sync settings:', error);
setMessage({ type: 'error', text: 'Failed to save auto-sync settings' });
} finally {
setIsSaving(false);
}
};
const handleAppriseUrlsChange = (text: string) => {
setAppriseUrlsText(text);
const urls = text.split('\n').filter(url => url.trim() !== '');
setAppriseUrls(urls);
};
const validateCronExpression = (cron: string) => {
if (!cron.trim()) {
setCronValidationError('');
return true;
}
// Basic cron validation - you might want to use a library like cron-validator
const cronRegex = /^(\*|([0-5]?\d)) (\*|([01]?\d|2[0-3])) (\*|([012]?\d|3[01])) (\*|([0]?\d|1[0-2])) (\*|([0-6]))$/;
const isValid = cronRegex.test(cron);
if (!isValid) {
setCronValidationError('Invalid cron expression format');
return false;
}
setCronValidationError('');
return true;
};
const handleCronChange = (cron: string) => {
setSyncIntervalCron(cron);
validateCronExpression(cron);
};
const testNotification = async () => {
try {
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ testNotification: true })
});
if (response.ok) {
setMessage({ type: 'success', text: 'Test notification sent successfully!' });
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to send test notification' });
}
} catch (error) {
console.error('Error sending test notification:', error);
setMessage({ type: 'error', text: 'Failed to send test notification' });
}
};
const triggerManualSync = async () => {
try {
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ triggerManualSync: true })
});
if (response.ok) {
setMessage({ type: 'success', text: 'Manual sync triggered successfully!' });
// Reload settings to get updated last sync time
await loadAutoSyncSettings();
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to trigger manual sync' });
}
} catch (error) {
console.error('Error triggering manual sync:', error);
setMessage({ type: 'error', text: 'Failed to trigger manual sync' });
}
};
if (!isOpen) return null;
return (
@@ -340,6 +589,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
>
Authentication
</Button>
<Button
onClick={() => setActiveTab('auto-sync')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'auto-sync'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Auto-Sync
</Button>
</nav>
</div>
@@ -499,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
{activeTab === 'auth' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-medium text-foreground">Authentication Settings</h3>
<ContextualHelpIcon section="auth-settings" tooltip="Help with Authentication Settings" />
</div>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure authentication to secure access to your application.
</p>
@@ -536,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
</div>
{isAuthenticated && expirationTime && (
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
<div className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Session expires in:</p>
<p className="text-sm font-medium text-foreground">{sessionExpirationDisplay}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expiration date:</p>
<p className="text-sm font-medium text-foreground">
{new Date(expirationTime).toLocaleString()}
</p>
</div>
</div>
</div>
)}
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
<p className="text-sm text-muted-foreground mb-4">
Configure how long user sessions should last before requiring re-authentication.
</p>
<div className="space-y-3">
<div>
<label htmlFor="session-duration" className="block text-sm font-medium text-foreground mb-1">
Session Duration (days)
</label>
<div className="flex items-center gap-3">
<Input
id="session-duration"
type="number"
min="1"
max="365"
placeholder="Enter days"
value={sessionDurationDays}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value)) {
setSessionDurationDays(value);
}
}}
disabled={authLoading || !authSetupCompleted}
className="w-32"
/>
<span className="text-sm text-muted-foreground">days (1-365)</span>
<Button
onClick={() => saveSessionDuration(sessionDurationDays)}
disabled={authLoading || !authSetupCompleted}
size="sm"
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Note: This setting applies to new logins. Current sessions will not be affected.
</p>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4">
@@ -623,6 +949,302 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
</div>
)}
{activeTab === 'auto-sync' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Auto-Sync Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure automatic synchronization of scripts with configurable intervals and notifications.
</p>
{/* Enable Auto-Sync */}
<div className="p-4 border border-border rounded-lg mb-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-foreground mb-1">Enable Auto-Sync</h4>
<p className="text-sm text-muted-foreground">Automatically sync JSON files from GitHub at specified intervals</p>
</div>
<Toggle
checked={autoSyncEnabled}
onCheckedChange={async (checked) => {
setAutoSyncEnabled(checked);
// Auto-save when toggle changes
try {
// If syncIntervalType is custom but no cron expression, fallback to predefined
const effectiveSyncIntervalType = (syncIntervalType === 'custom' && !syncIntervalCron)
? 'predefined'
: syncIntervalType;
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autoSyncEnabled: checked,
syncIntervalType: effectiveSyncIntervalType,
syncIntervalPredefined: effectiveSyncIntervalType === 'predefined' ? syncIntervalPredefined : undefined,
syncIntervalCron: effectiveSyncIntervalType === 'custom' ? syncIntervalCron : undefined,
autoDownloadNew,
autoUpdateExisting,
notificationEnabled,
appriseUrls: appriseUrls
})
});
if (response.ok) {
// Update local state to reflect the effective sync interval type
if (effectiveSyncIntervalType !== syncIntervalType) {
setSyncIntervalType(effectiveSyncIntervalType);
}
}
} catch (error) {
console.error('Error saving auto-sync toggle:', error);
}
}}
disabled={isSaving}
/>
</div>
</div>
{/* Sync Interval */}
{autoSyncEnabled && (
<div className="p-4 border border-border rounded-lg mb-4">
<h4 className="font-medium text-foreground mb-3">Sync Interval</h4>
<div className="space-y-3">
<div className="flex space-x-4">
<label className="flex items-center">
<input
type="radio"
name="syncIntervalType"
value="predefined"
checked={syncIntervalType === 'predefined'}
onChange={(e) => setSyncIntervalType(e.target.value as 'predefined' | 'custom')}
className="mr-2"
/>
Predefined
</label>
<label className="flex items-center">
<input
type="radio"
name="syncIntervalType"
value="custom"
checked={syncIntervalType === 'custom'}
onChange={(e) => setSyncIntervalType(e.target.value as 'predefined' | 'custom')}
className="mr-2"
/>
Custom Cron
</label>
</div>
{syncIntervalType === 'predefined' && (
<div>
<select
value={syncIntervalPredefined}
onChange={(e) => setSyncIntervalPredefined(e.target.value)}
className="w-full p-2 border border-border rounded-md bg-background"
>
<option value="15min">Every 15 minutes</option>
<option value="30min">Every 30 minutes</option>
<option value="1hour">Every hour</option>
<option value="6hours">Every 6 hours</option>
<option value="12hours">Every 12 hours</option>
<option value="24hours">Every 24 hours</option>
</select>
</div>
)}
{syncIntervalType === 'custom' && (
<div>
<Input
placeholder="0 */6 * * * (every 6 hours)"
value={syncIntervalCron}
onChange={(e) => handleCronChange(e.target.value)}
className="w-full"
autoFocus
onFocus={() => setCronValidationError('')}
/>
{cronValidationError && (
<p className="text-sm text-red-500 mt-1">{cronValidationError}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Format: minute hour day month weekday. See{' '}
<a href="https://crontab.guru" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
crontab.guru
</a>{' '}
for examples
</p>
<div className="mt-2 p-2 bg-muted rounded text-xs">
<p className="font-medium mb-1">Common examples:</p>
<ul className="space-y-1 text-muted-foreground">
<li> <code>* * * * *</code> - Every minute</li>
<li> <code>0 * * * *</code> - Every hour</li>
<li> <code>0 */6 * * *</code> - Every 6 hours</li>
<li> <code>0 0 * * *</code> - Every day at midnight</li>
<li> <code>0 0 * * 0</code> - Every Sunday at midnight</li>
</ul>
</div>
</div>
)}
</div>
</div>
)}
{/* Auto-Download Options */}
{autoSyncEnabled && (
<div className="p-4 border border-border rounded-lg mb-4">
<h4 className="font-medium text-foreground mb-3">Auto-Download Options</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h5 className="font-medium text-foreground">Auto-download new scripts</h5>
<p className="text-sm text-muted-foreground">Automatically download scripts that haven't been downloaded yet</p>
</div>
<Toggle
checked={autoDownloadNew}
onCheckedChange={setAutoDownloadNew}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between">
<div>
<h5 className="font-medium text-foreground">Auto-update existing scripts</h5>
<p className="text-sm text-muted-foreground">Automatically update scripts that have newer versions available</p>
</div>
<Toggle
checked={autoUpdateExisting}
onCheckedChange={setAutoUpdateExisting}
disabled={isSaving}
/>
</div>
</div>
</div>
)}
{/* Notifications */}
{autoSyncEnabled && (
<div className="p-4 border border-border rounded-lg mb-4">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="font-medium text-foreground">Enable Notifications</h4>
<p className="text-sm text-muted-foreground">Send notifications when sync completes</p>
<p className="text-xs text-muted-foreground mt-1">
If you want any other notification service, please open an issue on the GitHub repository.
</p>
</div>
<Toggle
checked={notificationEnabled}
onCheckedChange={setNotificationEnabled}
disabled={isSaving}
/>
</div>
{notificationEnabled && (
<div className="space-y-3">
<div>
<label htmlFor="apprise-urls" className="block text-sm font-medium text-foreground mb-1">
Apprise URLs
</label>
<textarea
id="apprise-urls"
placeholder="http://YOUR_APPRISE_SERVER/notify/apprise&#10;"
value={appriseUrlsText}
onChange={(e) => handleAppriseUrlsChange(e.target.value)}
className="w-full p-2 border border-border rounded-md bg-background h-24 resize-none"
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
One URL per line. Supports Discord, Telegram, Email, Slack, and more via{' '}
<a href="https://github.com/caronc/apprise" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Apprise
</a>
</p>
</div>
<div className="flex gap-2">
<Button
onClick={testNotification}
variant="outline"
size="sm"
disabled={appriseUrls.length === 0}
>
Test Notification
</Button>
</div>
</div>
)}
</div>
)}
{/* Status and Actions */}
{autoSyncEnabled && (
<div className="p-4 border border-border rounded-lg mb-4">
<h4 className="font-medium text-foreground mb-3">Status & Actions</h4>
<div className="space-y-3">
{lastAutoSync && (
<div>
<p className="text-sm text-muted-foreground">
Last sync: {new Date(lastAutoSync).toLocaleString()}
</p>
</div>
)}
{lastAutoSyncError && (
<div className="p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<p className="text-sm font-medium">Last sync error:</p>
<p className="text-sm mt-1">{lastAutoSyncError}</p>
{lastAutoSyncErrorTime && (
<p className="text-xs mt-1 opacity-75">
{new Date(lastAutoSyncErrorTime).toLocaleString()}
</p>
)}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={triggerManualSync}
variant="outline"
size="sm"
>
Trigger Sync Now
</Button>
<Button
onClick={saveAutoSyncSettings}
disabled={isSaving || (syncIntervalType === 'custom' && !!cronValidationError)}
size="sm"
>
{isSaving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
</div>
)}
{/* Message Display */}
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-success/10 text-success-foreground border border-success/20'
: 'bg-error/10 text-error-foreground border border-error/20'
}`}>
{message.text}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface HelpModalProps {
@@ -11,7 +11,7 @@ interface HelpModalProps {
initialSection?: string;
}
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -22,7 +22,9 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
const sections = [
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
{ id: 'auth-settings' as HelpSection, label: 'Authentication Settings', icon: Lock },
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
@@ -125,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li> Token is stored securely and only used for API calls</li>
</ul>
</div>
</div>
</div>
);
case 'auth-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Authentication Settings</h3>
<p className="text-muted-foreground mb-6">
Secure your application with username and password authentication and configure session management.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
<h4 className="font-medium text-foreground mb-2">Overview</h4>
<p className="text-sm text-muted-foreground mb-2">
Secure your application with username and password authentication.
Authentication settings allow you to secure access to your application with username and password protection.
Sessions persist across page refreshes, so users don&apos;t need to log in repeatedly.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Set up username and password for app access</li>
<li> Enable/disable authentication as needed</li>
<li> Credentials are stored securely</li>
<li> Credentials are stored securely using bcrypt hashing</li>
<li> Sessions use secure httpOnly cookies</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Setting Up Authentication</h4>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Navigate to General Settings Authentication tab</li>
<li>Enter a username (minimum 3 characters)</li>
<li>Enter a password (minimum 6 characters)</li>
<li>Confirm your password</li>
<li>Click &quot;Save Credentials&quot; to save your authentication settings</li>
<li>Toggle &quot;Enable Authentication&quot; to activate authentication</li>
</ol>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
<p className="text-sm text-muted-foreground mb-2">
Configure how long user sessions should last before requiring re-authentication.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Configurable Duration:</strong> Set session duration from 1 to 365 days</li>
<li> <strong>Default Duration:</strong> Sessions default to 7 days if not configured</li>
<li> <strong>Session Persistence:</strong> Sessions persist across page refreshes and browser restarts</li>
<li> <strong>New Logins Only:</strong> Duration changes apply to new logins, not existing sessions</li>
</ul>
<div className="mt-3 p-3 bg-info/10 rounded-md">
<h5 className="font-medium text-info-foreground mb-2">How to Configure:</h5>
<ol className="text-xs text-info/80 space-y-1 list-decimal list-inside">
<li>Go to General Settings Authentication tab</li>
<li>Find the &quot;Session Duration&quot; section</li>
<li>Enter the number of days (1-365)</li>
<li>Click &quot;Save&quot; to apply the setting</li>
</ol>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
<p className="text-sm text-muted-foreground mb-2">
When authenticated, you can view your current session information in the Authentication tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Time Until Expiration:</strong> See how much time remains before your session expires</li>
<li> <strong>Expiration Date:</strong> View the exact date and time your session will expire</li>
<li> <strong>Auto-Update:</strong> The expiration display updates every minute</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Updating Credentials</h4>
<p className="text-sm text-muted-foreground mb-2">
You can change your username and password at any time from the Authentication tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Update username without changing password (leave password fields empty)</li>
<li> Change password by entering a new password and confirmation</li>
<li> Both username and password can be updated together</li>
<li> Changes take effect immediately after saving</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-muted/50">
<h4 className="font-medium text-foreground mb-2">Security Features</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Password Hashing:</strong> Passwords are hashed using bcrypt before storage</li>
<li> <strong>Secure Cookies:</strong> Authentication tokens stored in httpOnly cookies</li>
<li> <strong>HTTPS in Production:</strong> Cookies are secure (HTTPS-only) in production mode</li>
<li> <strong>SameSite Protection:</strong> Cookies use strict SameSite policy to prevent CSRF attacks</li>
<li> <strong>JWT Tokens:</strong> Sessions use JSON Web Tokens with expiration</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
<h4 className="font-medium text-warning-foreground mb-2"> Important Notes</h4>
<ul className="text-sm text-warning/80 space-y-2">
<li> <strong>First-Time Setup:</strong> You must complete the initial setup before enabling authentication</li>
<li> <strong>Session Duration:</strong> Changes to session duration only affect new logins</li>
<li> <strong>Logout:</strong> You can log out manually, which immediately invalidates your session</li>
<li> <strong>Lost Credentials:</strong> If you forget your password, you&apos;ll need to reset it manually in the .env file</li>
<li> <strong>Disabling Auth:</strong> Disabling authentication clears all credentials and allows unrestricted access</li>
</ul>
</div>
</div>
@@ -185,6 +284,101 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
</div>
);
case 'auto-sync':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Auto-Sync</h3>
<p className="text-muted-foreground mb-6">
Configure automatic synchronization of scripts with configurable intervals and notifications.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">What Is Auto-Sync?</h4>
<p className="text-sm text-muted-foreground mb-2">
Auto-sync automatically synchronizes script metadata from the ProxmoxVE GitHub repository at specified intervals,
and optionally downloads/updates scripts and sends notifications.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Automatic JSON Sync:</strong> Downloads latest script metadata periodically</li>
<li> <strong>Auto-Download:</strong> Automatically download new scripts when available</li>
<li> <strong>Auto-Update:</strong> Automatically update existing scripts to newer versions</li>
<li> <strong>Notifications:</strong> Send notifications when sync completes</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Sync Intervals</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Predefined:</strong> Choose from common intervals (15min, 30min, 1hour, 6hours, 12hours, 24hours)</li>
<li> <strong>Custom Cron:</strong> Use cron expressions for advanced scheduling</li>
<li> <strong>Examples:</strong>
<ul className="ml-4 mt-1 space-y-1">
<li> <code>0 */6 * * *</code> - Every 6 hours</li>
<li> <code>0 0 * * *</code> - Daily at midnight</li>
<li> <code>0 9 * * 1</code> - Every Monday at 9 AM</li>
</ul>
</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Auto-Download Options</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Auto-download new scripts:</strong> Automatically download scripts that haven't been downloaded yet</li>
<li>• <strong>Auto-update existing scripts:</strong> Automatically update scripts that have newer versions available</li>
<li>• <strong>Selective Control:</strong> Enable/disable each option independently</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Notifications (Apprise)</h4>
<p className="text-sm text-muted-foreground mb-2">
Send notifications when sync completes using Apprise, which supports 80+ notification services.
If you want any other notification service, please open an issue on the GitHub repository.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li>• <strong>Apprise Server:</strong> <code>http://YOUR_APPRISE_SERVER/notify/apprise</code></li>
</ul>
<p className="text-xs text-muted-foreground mt-2">
See the <a href="https://github.com/caronc/apprise" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Apprise documentation</a> for more supported services.
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Setup Guide</h4>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Enable auto-sync in the General Settings → Auto-Sync tab</li>
<li>Choose your sync interval (predefined or custom cron)</li>
<li>Configure auto-download options if desired</li>
<li>Set up notifications by adding Apprise URLs</li>
<li>Test your notification setup using the "Test Notification" button</li>
<li>Save your settings to activate auto-sync</li>
</ol>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Cron Expression Help</h4>
<p className="text-sm text-muted-foreground mb-2">
Cron expressions have 5 fields: minute hour day month weekday
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• <strong>Minute:</strong> 0-59 or *</li>
<li>• <strong>Hour:</strong> 0-23 or *</li>
<li>• <strong>Day:</strong> 1-31 or *</li>
<li>• <strong>Month:</strong> 1-12 or *</li>
<li>• <strong>Weekday:</strong> 0-6 (Sunday=0) or *</li>
</ul>
<p className="text-xs text-muted-foreground mt-2">
Use <a href="https://crontab.guru" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">crontab.guru</a> to test and learn cron expressions.
</p>
</div>
</div>
</div>
);
case 'available-scripts':
return (
<div className="space-y-6">

View File

@@ -935,6 +935,18 @@ export function InstalledScriptsTab() {
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
<Button
onClick={() => {
cleanupRunRef.current = false; // Allow cleanup to run again
void cleanupMutation.mutate();
}}
disabled={cleanupMutation.isPending}
variant="outline"
size="default"
className="border-warning/30 text-warning hover:bg-warning/10"
>
{cleanupMutation.isPending ? '🧹 Cleaning up...' : '🧹 Cleanup Orphaned Scripts'}
</Button>
<Button
onClick={() => {
// Trigger status check by calling the mutation directly

View File

@@ -7,6 +7,8 @@ import type { Script } from "~/types/script";
import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal";
import { ConfirmationModal } from "./ConfirmationModal";
import { ScriptVersionModal } from "./ScriptVersionModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider';
@@ -37,6 +39,10 @@ export function ScriptDetailModal({
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
// Check if script files exist locally
const {
@@ -83,6 +89,31 @@ export function ScriptDetailModal({
},
});
// Delete script mutation
const deleteScriptMutation = api.scripts.deleteScript.useMutation({
onSuccess: (data) => {
setIsDeleting(false);
if (data.success) {
const message =
"message" in data ? data.message : "Script deleted successfully";
setLoadMessage(`[SUCCESS] ${message}`);
// Refetch script files status and comparison data to update the UI
void refetchScriptFiles();
void refetchComparison();
} else {
const error = "error" in data ? data.error : "Failed to delete script";
setLoadMessage(`[ERROR] ${error}`);
}
// Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000);
},
onError: (error) => {
setIsDeleting(false);
setLoadMessage(`[ERROR] ${error.message}`);
setTimeout(() => setLoadMessage(null), 5000);
},
});
if (!isOpen || !script) return null;
const handleImageError = () => {
@@ -105,16 +136,43 @@ export function ScriptDetailModal({
const handleInstallScript = () => {
if (!script) return;
// Check if script has multiple variants (default and alpine)
const installMethods = script.install_methods || [];
const hasMultipleVariants = installMethods.filter(method =>
method.type === 'default' || method.type === 'alpine'
).length > 1;
if (hasMultipleVariants) {
// Show version selection modal first
setVersionModalOpen(true);
} else {
// Only one variant, proceed directly to execution mode
// Use the first available method or default to 'default' type
const defaultMethod = installMethods.find(method => method.type === 'default');
const firstMethod = installMethods[0];
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
setExecutionModeOpen(true);
}
};
const handleVersionSelect = (versionType: string) => {
setSelectedVersionType(versionType);
setVersionModalOpen(false);
setExecutionModeOpen(true);
};
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
if (!script || !onInstallScript) return;
// Find the script path (CT or tools)
// Find the script path based on selected version type
const versionType = selectedVersionType || 'default';
const scriptMethod = script.install_methods?.find(
(method) => method.type === versionType && method.script,
) || script.install_methods?.find(
(method) => method.script,
);
if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name;
@@ -130,6 +188,19 @@ export function ScriptDetailModal({
setTextViewerOpen(true);
};
const handleDeleteScript = () => {
if (!script) return;
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = () => {
if (!script) return;
setDeleteConfirmOpen(false);
setIsDeleting(true);
setLoadMessage(null);
deleteScriptMutation.mutate({ slug: script.slug });
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
@@ -165,6 +236,20 @@ export function ScriptDetailModal({
{script.privileged && <PrivilegedBadge />}
</div>
</div>
{/* Interface Port*/}
{script.interface_port && (
<div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
Port:
</span>
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
{script.interface_port}
</span>
</div>
</div>
)}
</div>
{/* Close Button */}
@@ -359,6 +444,42 @@ export function ScriptDetailModal({
);
}
})()}
{/* Delete Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleDeleteScript}
disabled={isDeleting}
variant="destructive"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
>
{isDeleting ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Deleting...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span>Delete Script</span>
</>
)}
</Button>
)}
</div>
{/* Content */}
@@ -708,11 +829,22 @@ export function ScriptDetailModal({
?.script?.split("/")
.pop() ?? `${script.slug}.sh`
}
script={script}
isOpen={textViewerOpen}
onClose={() => setTextViewerOpen(false)}
/>
)}
{/* Version Selection Modal */}
{script && (
<ScriptVersionModal
script={script}
isOpen={versionModalOpen}
onClose={() => setVersionModalOpen(false)}
onSelectVersion={handleVersionSelect}
/>
)}
{/* Execution Mode Modal */}
{script && (
<ExecutionModeModal
@@ -722,6 +854,20 @@ export function ScriptDetailModal({
onExecute={handleExecuteScript}
/>
)}
{/* Delete Confirmation Modal */}
{script && (
<ConfirmationModal
isOpen={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleConfirmDelete}
title="Delete Script"
message={`Are you sure you want to delete all downloaded files for "${script.name}"? This action cannot be undone.`}
variant="simple"
confirmButtonText="Delete"
cancelButtonText="Cancel"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import { useState } from 'react';
import type { Script, ScriptInstallMethod } from '../../types/script';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
interface ScriptVersionModalProps {
isOpen: boolean;
onClose: () => void;
onSelectVersion: (versionType: string) => void;
script: Script | null;
}
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
if (!isOpen || !script) return null;
// Get available install methods
const installMethods = script.install_methods || [];
const defaultMethod = installMethods.find(method => method.type === 'default');
const alpineMethod = installMethods.find(method => method.type === 'alpine');
const handleConfirm = () => {
if (selectedVersion) {
onSelectVersion(selectedVersion);
onClose();
}
};
const handleVersionSelect = (versionType: string) => {
setSelectedVersion(versionType);
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
Choose a version for &quot;{script.name}&quot;
</h3>
<p className="text-sm text-muted-foreground">
Select the version you want to install. Each version has different resource requirements.
</p>
</div>
<div className="space-y-4">
{/* Default Version */}
{defaultMethod && (
<div
onClick={() => handleVersionSelect('default')}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'default'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'default'
? 'border-primary bg-primary'
: 'border-border'
}`}
>
{selectedVersion === 'default' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
{defaultMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{defaultMethod.resources.os} {defaultMethod.resources.version}
</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* Alpine Version */}
{alpineMethod && (
<div
onClick={() => handleVersionSelect('alpine')}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'alpine'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'alpine'
? 'border-primary bg-primary'
: 'border-border'
}`}
>
{selectedVersion === 'alpine' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
{alpineMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{alpineMethod.resources.os} {alpineMethod.resources.version}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedVersion}
variant="default"
size="default"
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
>
Continue
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -34,6 +34,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -535,7 +536,30 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
};
const handleDownloadAllFiltered = () => {
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
let scriptsToDownload: ScriptCardType[] = filteredScripts;
if (!hasActiveFilters) {
const scriptMap = new Map<string, ScriptCardType>();
filteredScripts.forEach(script => {
if (script?.slug) {
scriptMap.set(script.slug, script);
}
});
newestScripts.forEach(script => {
if (script?.slug && !scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
});
scriptsToDownload = Array.from(scriptMap.values());
}
const slugsToDownload = scriptsToDownload.map(script => script.slug).filter(Boolean);
if (slugsToDownload.length > 0) {
void downloadScriptsIndividually(slugsToDownload);
}
@@ -574,7 +598,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}, []);
const handleCardClick = (scriptCard: { slug: string }) => {
const handleCardClick = (scriptCard: ScriptCardType) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);
@@ -666,8 +690,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onViewModeChange={setViewMode}
/>
{/* Newest Scripts Carousel - Always show when there are newest scripts */}
{newestScripts.length > 0 && (
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
{newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
<div className="mb-8">
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center justify-between mb-4">
@@ -675,39 +699,64 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<Clock className="h-6 w-6 text-primary" />
Newest Scripts
</h2>
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
</div>
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
<Button
onClick={() => setIsNewestMinimized(!isNewestMinimized)}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isNewestMinimized ? "Expand newest scripts" : "Minimize newest scripts"}
>
<svg
className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
</div>
</div>
{!isNewestMinimized && (
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}

View File

@@ -53,6 +53,50 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
void loadColorCodingSetting();
}, []);
const validateServerAddress = (address: string): boolean => {
const trimmed = address.trim();
if (!trimmed) return false;
// IPv4 validation
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(trimmed)) {
return true;
}
// IPv6 validation (supports compressed format like ::1 and full format)
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
// Simplified validation: check for valid hex segments separated by colons
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv6Pattern.test(trimmed)) {
// Additional validation: ensure only one :: compression exists
const compressionCount = (trimmed.match(/::/g) || []).length;
if (compressionCount <= 1) {
return true;
}
}
// FQDN/hostname validation (RFC 1123 compliant)
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
// Max length 253 characters, each label max 63 characters
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
// Additional check: each label (between dots) must be max 63 chars
const labels = trimmed.split('.');
if (labels.every(label => label.length > 0 && label.length <= 63)) {
return true;
}
}
// Also allow simple hostnames without dots (like 'localhost')
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true;
}
return false;
};
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
@@ -61,12 +105,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
}
if (!formData.ip.trim()) {
newErrors.ip = 'IP address is required';
newErrors.ip = 'Server address is required';
} else {
// Basic IP validation
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipRegex.test(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address';
if (!validateServerAddress(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
}
}
@@ -221,7 +263,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
<div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address *
Host/IP Address *
</label>
<input
type="text"
@@ -231,7 +273,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., 192.168.1.100"
placeholder="e.g., 192.168.1.100, server.example.com, or 2001:db8::1"
/>
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div>

View File

@@ -4,77 +4,156 @@ import { useState, useEffect, useCallback } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Button } from './ui/button';
import type { Script } from '../../types/script';
interface TextViewerProps {
scriptName: string;
isOpen: boolean;
onClose: () => void;
script?: Script | null;
}
interface ScriptContent {
ctScript?: string;
installScript?: string;
alpineCtScript?: string;
alpineInstallScript?: string;
}
export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
// Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, '');
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// Check if alpine variant exists
const hasAlpineVariant = script?.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
// Get script names for default and alpine versions
const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const loadScriptContent = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// Try to load from different possible locations
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`),
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`),
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`),
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`),
// Build fetch requests for default version
const requests: Promise<Response>[] = [];
// Default CT script
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
);
// Tools, VM, VW scripts
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
);
// Default install script
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
]);
);
// Alpine versions if variant exists
if (hasAlpineVariant) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
);
}
const responses = await Promise.allSettled(requests);
const content: ScriptContent = {};
let responseIndex = 0;
if (ctResponse.status === 'fulfilled' && ctResponse.value.ok) {
// Default CT script
const ctResponse = responses[responseIndex];
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (ctData.result?.data?.json?.success) {
content.ctScript = ctData.result.data.json.content;
}
}
if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) {
responseIndex++;
// Tools script
const toolsResponse = responses[responseIndex];
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (toolsData.result?.data?.json?.success) {
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
}
}
if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) {
responseIndex++;
// VM script
const vmResponse = responses[responseIndex];
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vmData.result?.data?.json?.success) {
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
}
}
if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) {
responseIndex++;
// VW script
const vwResponse = responses[responseIndex];
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vwData.result?.data?.json?.success) {
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
}
}
if (installResponse.status === 'fulfilled' && installResponse.value.ok) {
responseIndex++;
// Default install script
const installResponse = responses[responseIndex];
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (installData.result?.data?.json?.success) {
content.installScript = installData.result.data.json.content;
}
}
responseIndex++;
// Alpine CT script
if (hasAlpineVariant) {
const alpineCtResponse = responses[responseIndex];
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineCtData.result?.data?.json?.success) {
content.alpineCtScript = alpineCtData.result.data.json.content;
}
}
responseIndex++;
}
// Alpine install script
if (hasAlpineVariant) {
const alpineInstallResponse = responses[responseIndex];
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineInstallData.result?.data?.json?.success) {
content.alpineInstallScript = alpineInstallData.result.data.json.content;
}
}
}
setScriptContent(content);
} catch (err) {
@@ -82,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
} finally {
setIsLoading(false);
}
}, [scriptName, slug]);
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
useEffect(() => {
if (isOpen && scriptName) {
@@ -106,11 +185,30 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-4 flex-1">
<h2 className="text-2xl font-bold text-foreground">
Script Viewer: {scriptName}
Script Viewer: {defaultScriptName}
</h2>
{scriptContent.ctScript && scriptContent.installScript && (
{hasAlpineVariant && (
<div className="flex space-x-2">
<Button
variant={selectedVersion === 'default' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('default')}
className="px-3 py-1 text-sm"
>
Default
</Button>
<Button
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('alpine')}
className="px-3 py-1 text-sm"
>
Alpine
</Button>
</div>
)}
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
<div className="flex space-x-2">
<Button
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
@@ -151,44 +249,87 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
</div>
) : (
<div className="flex-1 overflow-auto">
{activeTab === 'ct' && scriptContent.ctScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.ctScript}
</SyntaxHighlighter>
) : activeTab === 'install' && scriptContent.installScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.installScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{activeTab === 'ct' ? 'CT script not found' : 'Install script not found'}
{activeTab === 'ct' && (
selectedVersion === 'default' && scriptContent.ctScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.ctScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.alpineCtScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
</div>
</div>
</div>
)
)}
{activeTab === 'install' && (
selectedVersion === 'default' && scriptContent.installScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.installScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.alpineInstallScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
</div>
</div>
)
)}
</div>
)}

View File

@@ -38,7 +38,8 @@ export async function POST(request: NextRequest) {
);
}
const token = generateToken(username);
const sessionDurationDays = authConfig.sessionDurationDays;
const token = generateToken(username, sessionDurationDays);
const response = NextResponse.json({
success: true,
@@ -46,12 +47,12 @@ export async function POST(request: NextRequest) {
username
});
// Set httpOnly cookie
// Set httpOnly cookie with configured duration
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 days
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/',
});

View File

@@ -22,10 +22,17 @@ export async function GET(request: NextRequest) {
);
}
// Calculate expiration time in milliseconds
const expirationTime = decoded.exp ? decoded.exp * 1000 : null;
const currentTime = Date.now();
const timeUntilExpiration = expirationTime ? expirationTime - currentTime : null;
return NextResponse.json({
success: true,
username: decoded.username,
authenticated: true
authenticated: true,
expirationTime,
timeUntilExpiration
});
} catch (error) {
console.error('Error verifying token:', error);

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration } from '~/lib/auth';
import fs from 'fs';
import path from 'path';
import { withApiLogging } from '../../../../server/logging/withApiLogging';
@@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() {
enabled: authConfig.enabled,
hasCredentials: authConfig.hasCredentials,
setupCompleted: authConfig.setupCompleted,
sessionDurationDays: authConfig.sessionDurationDays,
});
} catch {
// Error handled by withApiLogging
@@ -66,48 +67,75 @@ export const POST = withApiLogging(async function POST(request: NextRequest) {
export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
try {
const { enabled } = await request.json() as { enabled: boolean };
const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number };
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled flag must be a boolean' },
{ status: 400 }
);
}
if (enabled) {
// When enabling, just update the flag
updateAuthEnabled(enabled);
} else {
// When disabling, clear all credentials and set flag to false
const envPath = path.join(process.cwd(), '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
if (body.enabled !== undefined) {
const { enabled } = body;
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled flag must be a boolean' },
{ status: 400 }
);
}
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
if (enabled) {
// When enabling, just update the flag
updateAuthEnabled(enabled);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
// When disabling, clear all credentials and set flag to false
const envPath = path.join(process.cwd(), '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
}
// Clean up empty lines
envContent = envContent.replace(/\n\n+/g, '\n');
fs.writeFileSync(envPath, envContent);
}
// Clean up empty lines
envContent = envContent.replace(/\n\n+/g, '\n');
fs.writeFileSync(envPath, envContent);
return NextResponse.json({
success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
});
}
return NextResponse.json({
success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
});
if (body.sessionDurationDays !== undefined) {
const { sessionDurationDays } = body;
if (typeof sessionDurationDays !== 'number' || sessionDurationDays < 1 || sessionDurationDays > 365) {
return NextResponse.json(
{ error: 'Session duration must be a number between 1 and 365 days' },
{ status: 400 }
);
}
updateSessionDuration(sessionDurationDays);
return NextResponse.json({
success: true,
message: `Session duration updated to ${sessionDurationDays} days`
});
}
return NextResponse.json(
{ error: 'No valid field to update' },
{ status: 400 }
);
} catch {
// Error handled by withApiLogging
return NextResponse.json(

View File

@@ -0,0 +1,396 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { isValidCron } from 'cron-validator';
export async function POST(request: NextRequest) {
try {
const settings = await request.json();
if (!settings || typeof settings !== 'object') {
return NextResponse.json(
{ error: 'Settings object is required' },
{ status: 400 }
);
}
// Handle test notification request
if (settings.testNotification) {
return await handleTestNotification();
}
// Handle manual sync trigger
if (settings.triggerManualSync) {
return await handleManualSync();
}
// Validate required fields for settings save
const requiredFields = [
'autoSyncEnabled',
'syncIntervalType',
'autoDownloadNew',
'autoUpdateExisting',
'notificationEnabled'
];
for (const field of requiredFields) {
if (!(field in settings)) {
return NextResponse.json(
{ error: `Missing required field: ${field}` },
{ status: 400 }
);
}
}
// Validate sync interval type
if (!['predefined', 'custom'].includes(settings.syncIntervalType)) {
return NextResponse.json(
{ error: 'syncIntervalType must be "predefined" or "custom"' },
{ status: 400 }
);
}
// Validate predefined interval
if (settings.syncIntervalType === 'predefined') {
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
if (!validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json(
{ error: 'Invalid predefined interval' },
{ status: 400 }
);
}
}
// Validate custom cron expression
if (settings.syncIntervalType === 'custom') {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
// Fallback to predefined if custom is selected but no cron expression
settings.syncIntervalType = 'predefined';
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
settings.syncIntervalCron = '';
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json(
{ error: 'Invalid cron expression' },
{ status: 400 }
);
}
}
// Validate Apprise URLs if notifications are enabled
if (settings.notificationEnabled && settings.appriseUrls) {
try {
// Handle both array and JSON string formats
let urls;
if (Array.isArray(settings.appriseUrls)) {
urls = settings.appriseUrls;
} else if (typeof settings.appriseUrls === 'string') {
urls = JSON.parse(settings.appriseUrls);
} else {
return NextResponse.json(
{ error: 'Apprise URLs must be an array or JSON string' },
{ status: 400 }
);
}
if (!Array.isArray(urls)) {
return NextResponse.json(
{ error: 'Apprise URLs must be an array' },
{ status: 400 }
);
}
// Basic URL validation
for (const url of urls) {
if (typeof url !== 'string' || url.trim() === '') {
return NextResponse.json(
{ error: 'All Apprise URLs must be non-empty strings' },
{ status: 400 }
);
}
}
} catch (parseError) {
return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 }
);
}
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Auto-sync settings to add/update
const autoSyncSettings = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
};
// Update or add each setting
for (const [key, value] of Object.entries(autoSyncSettings)) {
const regex = new RegExp(`^${key}=.*$`, 'm');
const settingLine = `${key}="${value}"`;
if (regex.test(envContent)) {
// Replace existing setting
envContent = envContent.replace(regex, settingLine);
} else {
// Add new setting
envContent += (envContent.endsWith('\n') ? '' : '\n') + `${settingLine}\n`;
}
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
// Reschedule auto-sync service with new settings
try {
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Update the global service instance with new settings
autoSyncService.saveSettings(settings);
if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
// Also stop the global service instance if it exists
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
stopGlobalAutoSync();
}
} catch (error) {
console.error('Error rescheduling auto-sync service:', error);
// Don't fail the request if rescheduling fails
}
return NextResponse.json({
success: true,
message: 'Auto-sync settings saved successfully'
});
} catch (error) {
console.error('Error saving auto-sync settings:', error);
return NextResponse.json(
{ error: 'Failed to save auto-sync settings' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({
settings: {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
syncIntervalPredefined: '1hour',
syncIntervalCron: '',
autoDownloadNew: false,
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: null,
lastAutoSyncErrorTime: null
}
});
}
// Read .env file and extract auto-sync settings
const envContent = fs.readFileSync(envPath, 'utf8');
const settings = {
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '',
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
appriseUrls: (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue);
} catch {
return [];
}
})(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
};
return NextResponse.json({ settings });
} catch (error) {
console.error('Error reading auto-sync settings:', error);
return NextResponse.json(
{ error: 'Failed to read auto-sync settings' },
{ status: 500 }
);
}
}
// Helper function to handle test notification
async function handleTestNotification() {
try {
// Load current settings
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json(
{ error: 'No auto-sync settings found' },
{ status: 404 }
);
}
const envContent = fs.readFileSync(envPath, 'utf8');
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
const appriseUrls = (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue);
} catch {
return [];
}
})();
if (!notificationEnabled) {
return NextResponse.json(
{ error: 'Notifications are not enabled' },
{ status: 400 }
);
}
if (!appriseUrls || appriseUrls.length === 0) {
return NextResponse.json(
{ error: 'No Apprise URLs configured' },
{ status: 400 }
);
}
// Send test notification using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification();
if (result.success) {
return NextResponse.json({
success: true,
message: 'Test notification sent successfully'
});
} else {
return NextResponse.json(
{ error: result.message },
{ status: 500 }
);
}
} catch (error) {
console.error('Error sending test notification:', error);
return NextResponse.json(
{ error: 'Failed to send test notification' },
{ status: 500 }
);
}
}
// Helper function to handle manual sync trigger
async function handleManualSync() {
try {
// Load current settings
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json(
{ error: 'No auto-sync settings found' },
{ status: 404 }
);
}
const envContent = fs.readFileSync(envPath, 'utf8');
const autoSyncEnabled = getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true';
if (!autoSyncEnabled) {
return NextResponse.json(
{ error: 'Auto-sync is not enabled' },
{ status: 400 }
);
}
// Trigger manual sync using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as any;
if (result && result.success) {
return NextResponse.json({
success: true,
message: 'Manual sync completed successfully',
result
});
} else {
return NextResponse.json(
{ error: result.message },
{ status: 500 }
);
}
} catch (error) {
console.error('Error triggering manual sync:', error);
return NextResponse.json(
{ error: 'Failed to trigger manual sync' },
{ status: 500 }
);
}
}
// Helper function to extract value from .env content
function getEnvValue(envContent: string, key: string): string {
// Try to match the pattern with quotes around the value (handles nested quotes)
const regex = new RegExp(`^${key}="(.+)"$`, 'm');
let match = regex.exec(envContent);
if (match && match[1]) {
let value = match[1];
// Remove extra quotes that might be around JSON values
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
return value;
}
// Try to match without quotes (fallback)
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
match = regexNoQuotes.exec(envContent);
if (match && match[1]) {
return match[1];
}
return '';
}

View File

@@ -16,10 +16,12 @@ import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react';
import { api } from '~/trpc/react';
import { useAuth } from './_components/AuthProvider';
export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
@@ -152,7 +154,19 @@ export default function Home() {
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<span className="break-words">PVE Scripts Management</span>
</h1>
<div className="flex-1 flex justify-end">
<div className="flex-1 flex justify-end items-center gap-2">
{isAuthenticated && (
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Logout"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
)}
<ThemeToggle />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import fs from 'fs';
import path from 'path';
const SALT_ROUNDS = 10;
const JWT_EXPIRY = '7d'; // 7 days
const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days
// Cache for JWT secret to avoid multiple file reads
let jwtSecretCache: string | null = null;
@@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise<b
/**
* Generate a JWT token
*/
export function generateToken(username: string): string {
export function generateToken(username: string, durationDays?: number): string {
const secret = getJwtSecret();
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
const days = durationDays ?? DEFAULT_JWT_EXPIRY_DAYS;
return jwt.sign({ username }, secret, { expiresIn: `${days}d` });
}
/**
* Decode a JWT token without verification (for extracting expiration time)
*/
export function decodeToken(token: string): { username: string; exp?: number; iat?: number } | null {
try {
const decoded = jwt.decode(token) as { username: string; exp?: number; iat?: number } | null;
return decoded;
} catch {
return null;
}
}
/**
* Verify a JWT token
*/
export function verifyToken(token: string): { username: string } | null {
export function verifyToken(token: string): { username: string; exp?: number; iat?: number } | null {
try {
const secret = getJwtSecret();
const decoded = jwt.verify(token, secret) as { username: string };
const decoded = jwt.verify(token, secret) as { username: string; exp?: number; iat?: number };
return decoded;
} catch {
return null;
@@ -93,6 +106,7 @@ export function getAuthConfig(): {
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
sessionDurationDays: number;
} {
const envPath = path.join(process.cwd(), '.env');
@@ -103,6 +117,7 @@ export function getAuthConfig(): {
enabled: false,
hasCredentials: false,
setupCompleted: false,
sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS,
};
}
@@ -128,6 +143,13 @@ export function getAuthConfig(): {
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
// Extract AUTH_SESSION_DURATION_DAYS
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
const sessionDurationDays = sessionDurationMatch
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
: DEFAULT_JWT_EXPIRY_DAYS;
const hasCredentials = !!(username && passwordHash);
return {
@@ -136,6 +158,7 @@ export function getAuthConfig(): {
enabled,
hasCredentials,
setupCompleted,
sessionDurationDays,
};
}
@@ -238,3 +261,30 @@ export function updateAuthEnabled(enabled: boolean): void {
fs.writeFileSync(envPath, envContent);
}
/**
* Update AUTH_SESSION_DURATION_DAYS in .env
*/
export function updateSessionDuration(days: number): void {
// Validate: between 1 and 365 days
const validDays = Math.max(1, Math.min(365, Math.floor(days)));
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Update or add AUTH_SESSION_DURATION_DAYS
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=.*$/m;
if (sessionDurationRegex.test(envContent)) {
envContent = envContent.replace(sessionDurationRegex, `AUTH_SESSION_DURATION_DAYS=${validDays}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_SESSION_DURATION_DAYS=${validDays}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}

View File

@@ -887,77 +887,142 @@ export const installedScriptsRouter = createTRPCRouter({
);
// Group scripts by server to batch check containers
const scriptsByServer = new Map<number, any[]>();
for (const script of scriptsToCheck) {
const scriptData = script as any;
if (!scriptData.server_id) continue;
if (!scriptsByServer.has(scriptData.server_id)) {
scriptsByServer.set(scriptData.server_id, []);
}
scriptsByServer.get(scriptData.server_id)!.push(scriptData);
}
// Process each server
for (const [serverId, serverScripts] of scriptsByServer.entries()) {
try {
const scriptData = script as any;
const server = allServers.find((s: any) => s.id === scriptData.server_id);
const server = allServers.find((s: any) => s.id === serverId);
if (!server) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
// Server doesn't exist, delete all scripts for this server
for (const scriptData of serverScripts) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
}
continue;
}
// Test SSH connection
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
console.warn(`cleanupOrphanedScripts: SSH connection failed for server ${String((server as any).name)}, skipping ${serverScripts.length} scripts`);
continue;
}
// Check if the container config file still exists
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
// Get all existing containers from pct list (more reliable than checking config files)
const listCommand = 'pct list';
let listOutput = '';
// Await full command completion to avoid early false negatives
const containerExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
if (out.includes('exists')) {
resolve(true);
} else if (out.includes('not_found')) {
resolve(false);
} else {
// Unknown output; treat as not found but log for diagnostics
console.warn(`cleanupOrphanedScripts: unexpected output for ${String(scriptData.script_name)} (${String(scriptData.container_id)}): ${out}`);
resolve(false);
}
};
// Add a guard timeout so we don't hang indefinitely
const timer = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while checking ${String(scriptData.script_name)} on server ${String((server as any).name)}`);
finish();
}, 15000);
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no containers found
}, 20000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
listCommand,
(data: string) => {
combinedOutput += data;
listOutput += data;
},
(error: string) => {
combinedOutput += error;
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
clearTimeout(timeout);
resolve(new Set()); // Treat error as no containers found
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
clearTimeout(timeout);
// Parse pct list output to extract container IDs
const containerIds = new Set<string>();
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Skip header line if present
if (line.includes('CTID') || line.includes('VMID')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const containerId = parts[0]?.trim();
if (containerId && /^\d{3,4}$/.test(containerId)) {
containerIds.add(containerId);
}
}
}
resolve(containerIds);
}
);
});
if (!containerExists) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
}
// Check each script against the list of existing containers
for (const scriptData of serverScripts) {
try {
const containerId = String(scriptData.container_id).trim();
// Check if container exists in pct list
if (!existingContainerIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
resolve(out.includes('exists'));
};
const timer = setTimeout(() => {
finish();
}, 10000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
(data: string) => {
combinedOutput += data;
},
(_error: string) => {
// Ignore errors, just check output
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
}
);
});
// If container is not in pct list AND config file doesn't exist, it's orphaned
if (!configExists) {
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
// Config exists but not in pct list - might be in a transitional state, log but don't delete
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
}
}
} catch (error) {
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error);
}
}
} catch (error) {
console.error(`Error checking script ${(script as any).script_name}:`, error);
console.error(`cleanupOrphanedScripts: Error processing server ${serverId}:`, error);
}
}

View File

@@ -4,6 +4,7 @@ import { scriptManager } from "~/server/lib/scripts";
import { githubJsonService } from "~/server/services/githubJsonService";
import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader";
import { AutoSyncService } from "~/server/services/autoSyncService";
import type { ScriptCard } from "~/types/script";
export const scriptsRouter = createTRPCRouter({
@@ -114,6 +115,18 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
try {
console.log('getScriptBySlug called with slug:', input.slug);
console.log('githubJsonService methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(githubJsonService)));
console.log('githubJsonService.getScriptBySlug type:', typeof githubJsonService.getScriptBySlug);
if (typeof githubJsonService.getScriptBySlug !== 'function') {
return {
success: false,
error: 'getScriptBySlug method is not available on githubJsonService',
script: null
};
}
const script = await githubJsonService.getScriptBySlug(input.slug);
if (!script) {
return {
@@ -124,6 +137,7 @@ export const scriptsRouter = createTRPCRouter({
}
return { success: true, script };
} catch (error) {
console.error('Error in getScriptBySlug:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch script',
@@ -349,6 +363,34 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Delete script files
deleteScript: publicProcedure
.input(z.object({ slug: z.string() }))
.mutation(async ({ input }) => {
try {
// Get the script details
const script = await localScriptsService.getScriptBySlug(input.slug);
if (!script) {
return {
success: false,
error: 'Script not found',
deletedFiles: []
};
}
// Delete the script files
const result = await scriptDownloaderService.deleteScript(script);
return result;
} catch (error) {
console.error('Error in deleteScript:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles: []
};
}
}),
// Compare local and remote script content
compareScriptContent: publicProcedure
.input(z.object({ slug: z.string() }))
@@ -457,5 +499,121 @@ export const scriptsRouter = createTRPCRouter({
message: 'Failed to check Proxmox VE status'
};
}
}),
// Auto-sync settings and operations
getAutoSyncSettings: publicProcedure
.query(async () => {
try {
const autoSyncService = new AutoSyncService();
const settings = autoSyncService.loadSettings();
return { success: true, settings };
} catch (error) {
console.error('Error getting auto-sync settings:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get auto-sync settings',
settings: null
};
}
}),
saveAutoSyncSettings: publicProcedure
.input(z.object({
autoSyncEnabled: z.boolean(),
syncIntervalType: z.enum(['predefined', 'custom']),
syncIntervalPredefined: z.string().optional(),
syncIntervalCron: z.string().optional(),
autoDownloadNew: z.boolean(),
autoUpdateExisting: z.boolean(),
notificationEnabled: z.boolean(),
appriseUrls: z.array(z.string()).optional()
}))
.mutation(async ({ input }) => {
try {
// Use the global auto-sync service instance
const { getAutoSyncService, setAutoSyncService } = await import('~/server/lib/autoSyncInit');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('~/server/services/autoSyncService');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Save settings to both .env file and service instance
autoSyncService.saveSettings(input);
// Reschedule auto-sync if enabled
if (input.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
console.log('Auto-sync rescheduled with new settings');
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
console.log('Auto-sync stopped');
}
return { success: true, message: 'Auto-sync settings saved successfully' };
} catch (error) {
console.error('Error saving auto-sync settings:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save auto-sync settings'
};
}
}),
testNotification: publicProcedure
.mutation(async () => {
try {
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification();
return result;
} catch (error) {
console.error('Error testing notification:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to test notification'
};
}
}),
triggerManualAutoSync: publicProcedure
.mutation(async () => {
try {
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync();
return {
success: true,
message: 'Manual auto-sync completed successfully',
result
};
} catch (error) {
console.error('Error in manual auto-sync:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to execute manual auto-sync',
result: null
};
}
}),
getAutoSyncStatus: publicProcedure
.query(async () => {
try {
const autoSyncService = new AutoSyncService();
const status = autoSyncService.getStatus();
return { success: true, status };
} catch (error) {
console.error('Error getting auto-sync status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get auto-sync status',
status: null
};
}
})
});

View File

@@ -0,0 +1,84 @@
import { AutoSyncService } from '../services/autoSyncService.js';
let autoSyncService = null;
let isInitialized = false;
/**
* Initialize auto-sync service and schedule cron job if enabled
*/
export function initializeAutoSync() {
if (isInitialized) {
console.log('Auto-sync service already initialized, skipping...');
return;
}
try {
console.log('Initializing auto-sync service...');
autoSyncService = new AutoSyncService();
isInitialized = true;
console.log('AutoSyncService instance created');
// Load settings and schedule if enabled
const settings = autoSyncService.loadSettings();
console.log('Settings loaded:', settings);
if (settings.autoSyncEnabled) {
console.log('Auto-sync is enabled, scheduling cron job...');
autoSyncService.scheduleAutoSync();
console.log('Cron job scheduled');
} else {
console.log('Auto-sync is disabled');
}
console.log('Auto-sync service initialized successfully');
} catch (error) {
console.error('Failed to initialize auto-sync service:', error);
console.error('Error stack:', error.stack);
}
}
/**
* Stop auto-sync service and clean up cron jobs
*/
export function stopAutoSync() {
try {
if (autoSyncService) {
console.log('Stopping auto-sync service...');
autoSyncService.stopAutoSync();
autoSyncService = null;
isInitialized = false;
console.log('Auto-sync service stopped');
}
} catch (error) {
console.error('Error stopping auto-sync service:', error);
}
}
/**
* Get the auto-sync service instance
*/
export function getAutoSyncService() {
return autoSyncService;
}
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service) {
autoSyncService = service;
}
/**
* Graceful shutdown handler
*/
export function setupGracefulShutdown() {
const shutdown = (signal) => {
console.log(`Received ${signal}, shutting down gracefully...`);
stopAutoSync();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGUSR2', () => shutdown('SIGUSR2')); // For nodemon
}

View File

@@ -0,0 +1,72 @@
import { AutoSyncService } from '~/server/services/autoSyncService';
let autoSyncService: AutoSyncService | null = null;
/**
* Initialize auto-sync service and schedule cron job if enabled
*/
export function initializeAutoSync(): void {
try {
console.log('Initializing auto-sync service...');
autoSyncService = new AutoSyncService();
// Load settings and schedule if enabled
const settings = autoSyncService.loadSettings();
if (settings.autoSyncEnabled) {
console.log('Auto-sync is enabled, scheduling cron job...');
autoSyncService.scheduleAutoSync();
} else {
console.log('Auto-sync is disabled');
}
console.log('Auto-sync service initialized successfully');
} catch (error) {
console.error('Failed to initialize auto-sync service:', error);
}
}
/**
* Stop auto-sync service and clean up cron jobs
*/
export function stopAutoSync(): void {
try {
if (autoSyncService) {
console.log('Stopping auto-sync service...');
autoSyncService.stopAutoSync();
autoSyncService = null;
console.log('Auto-sync service stopped');
}
} catch (error) {
console.error('Error stopping auto-sync service:', error);
}
}
/**
* Get the auto-sync service instance
*/
export function getAutoSyncService(): AutoSyncService | null {
return autoSyncService;
}
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service: AutoSyncService | null): void {
autoSyncService = service;
}
/**
* Graceful shutdown handler
*/
export function setupGracefulShutdown(): void {
const shutdown = (signal: string) => {
console.log(`Received ${signal}, shutting down gracefully...`);
stopAutoSync();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGUSR2', () => shutdown('SIGUSR2')); // For nodemon
}

View File

@@ -25,6 +25,25 @@ export class ScriptManager {
// Initialize lazily to avoid accessing env vars during module load
}
/**
* Safely handle file modification time, providing fallback for invalid dates
* @param mtime - The file modification time from fs.stat
* @returns Date - Valid date or current date as fallback
*/
private safeMtime(mtime: Date): Date {
try {
// Check if the date is valid
if (!mtime || isNaN(mtime.getTime())) {
console.warn('Invalid mtime detected, using current time as fallback');
return new Date();
}
return mtime;
} catch (error) {
console.warn('Error processing mtime:', error);
return new Date();
}
}
private initializeConfig() {
if (this.scriptsDir === null) {
// Handle both absolute and relative paths for testing
@@ -63,7 +82,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
lastModified: this.safeMtime(stats.mtime),
executable
});
}
@@ -125,7 +144,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
lastModified: this.safeMtime(stats.mtime),
executable,
logo,
slug
@@ -212,7 +231,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
lastModified: this.safeMtime(stats.mtime),
executable,
logo,
slug

View File

@@ -0,0 +1,123 @@
import axios from 'axios';
export class AppriseService {
constructor() {
this.baseUrl = 'http://localhost:8080'; // Default Apprise API URL
}
/**
* Send notification via Apprise
* @param {string} title - Notification title
* @param {string} body - Notification body
* @param {string[]} urls - Array of Apprise URLs
*/
async sendNotification(title, body, urls) {
if (!urls || urls.length === 0) {
throw new Error('No Apprise URLs provided');
}
try {
// Format the notification as form data (Apprise API expects form data)
const formData = new URLSearchParams();
formData.append('body', body || '');
formData.append('title', title || 'PVE Scripts Local');
formData.append('tags', 'all');
// Send to each URL
const results = [];
for (const url of urls) {
try {
const response = await axios.post(url, formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 10000 // 10 second timeout
});
results.push({
url,
success: true,
status: response.status
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Failed to send notification to ${url}:`, errorMessage);
results.push({
url,
success: false,
error: errorMessage
});
}
}
// Check if any notifications succeeded
const successCount = results.filter(r => r.success).length;
if (successCount === 0) {
throw new Error('All notification attempts failed');
}
return {
success: true,
message: `Notification sent to ${successCount}/${urls.length} services`,
results
};
} catch (error) {
console.error('Apprise notification failed:', error);
throw error;
}
}
/**
* Test notification to a single URL
* @param {string} url - Apprise URL to test
*/
async testUrl(url) {
try {
await this.sendNotification('Test', 'This is a test notification', [url]);
return { success: true, message: 'Test notification sent successfully' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Validate Apprise URL format
* @param {string} url - URL to validate
*/
validateUrl(url) {
if (!url || typeof url !== 'string') {
return { valid: false, error: 'URL is required' };
}
// Basic URL validation
try {
new URL(url);
} catch {
return { valid: false, error: 'Invalid URL format' };
}
// Check for common Apprise URL patterns
const apprisePatterns = [
/^discord:\/\//,
/^tgram:\/\//,
/^mailto:\/\//,
/^slack:\/\//,
/^https?:\/\//
];
const isValidAppriseUrl = apprisePatterns.some(pattern => pattern.test(url));
if (!isValidAppriseUrl) {
return {
valid: false,
error: 'URL does not match known Apprise service patterns'
};
}
return { valid: true };
}
}
export const appriseService = new AppriseService();

View File

@@ -0,0 +1,758 @@
import cron from 'node-cron';
import { githubJsonService } from './githubJsonService.js';
import { scriptDownloaderService } from './scriptDownloader.js';
import { appriseService } from './appriseService.js';
import { readFile, writeFile, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import cronValidator from 'cron-validator';
// Global lock to prevent multiple autosync instances from running simultaneously
let globalAutoSyncLock = false;
export class AutoSyncService {
constructor() {
this.cronJob = null;
this.isRunning = false;
}
/**
* Safely convert a date to ISO string, handling invalid dates
* @param {Date} date - The date to convert
* @returns {string} - ISO string or fallback timestamp
*/
safeToISOString(date) {
try {
// Check if the date is valid
if (!date || isNaN(date.getTime())) {
console.warn('Invalid date provided to safeToISOString, using current time as fallback');
return new Date().toISOString();
}
return date.toISOString();
} catch (error) {
console.warn('Error converting date to ISO string:', error instanceof Error ? error.message : String(error));
return new Date().toISOString();
}
}
/**
* Load auto-sync settings from .env file
*/
loadSettings() {
try {
const envPath = join(process.cwd(), '.env');
const envContent = readFileSync(envPath, 'utf8');
/** @type {{
* autoSyncEnabled: boolean;
* syncIntervalType: string;
* syncIntervalPredefined?: string;
* syncIntervalCron?: string;
* autoDownloadNew: boolean;
* autoUpdateExisting: boolean;
* notificationEnabled: boolean;
* appriseUrls?: string[];
* lastAutoSync?: string;
* lastAutoSyncError?: string;
* lastAutoSyncErrorTime?: string;
* }} */
const settings = {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
syncIntervalPredefined: '1hour',
syncIntervalCron: '',
autoDownloadNew: false,
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
};
const lines = envContent.split('\n');
for (const line of lines) {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
let value = valueParts.join('=').trim();
// Remove surrounding quotes if present
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
switch (key.trim()) {
case 'AUTO_SYNC_ENABLED':
settings.autoSyncEnabled = value === 'true';
break;
case 'SYNC_INTERVAL_TYPE':
settings.syncIntervalType = value;
break;
case 'SYNC_INTERVAL_PREDEFINED':
settings.syncIntervalPredefined = value;
break;
case 'SYNC_INTERVAL_CRON':
settings.syncIntervalCron = value;
break;
case 'AUTO_DOWNLOAD_NEW':
settings.autoDownloadNew = value === 'true';
break;
case 'AUTO_UPDATE_EXISTING':
settings.autoUpdateExisting = value === 'true';
break;
case 'NOTIFICATION_ENABLED':
settings.notificationEnabled = value === 'true';
break;
case 'APPRISE_URLS':
try {
settings.appriseUrls = JSON.parse(value || '[]');
} catch {
settings.appriseUrls = [];
}
break;
case 'LAST_AUTO_SYNC':
settings.lastAutoSync = value;
break;
case 'LAST_AUTO_SYNC_ERROR':
settings.lastAutoSyncError = value;
break;
case 'LAST_AUTO_SYNC_ERROR_TIME':
settings.lastAutoSyncErrorTime = value;
break;
}
}
}
return settings;
} catch (error) {
console.error('Error loading auto-sync settings:', error);
return {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
syncIntervalPredefined: '1hour',
syncIntervalCron: '',
autoDownloadNew: false,
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
};
}
}
/**
* Save auto-sync settings to .env file
* @param {Object} settings - Settings object
* @param {boolean} settings.autoSyncEnabled
* @param {string} settings.syncIntervalType
* @param {string} [settings.syncIntervalPredefined]
* @param {string} [settings.syncIntervalCron]
* @param {boolean} settings.autoDownloadNew
* @param {boolean} settings.autoUpdateExisting
* @param {boolean} settings.notificationEnabled
* @param {Array<string>} [settings.appriseUrls]
* @param {string} [settings.lastAutoSync]
* @param {string} [settings.lastAutoSyncError]
* @param {string} [settings.lastAutoSyncErrorTime]
*/
saveSettings(settings) {
try {
const envPath = join(process.cwd(), '.env');
let envContent = '';
try {
envContent = readFileSync(envPath, 'utf8');
} catch {
// .env file doesn't exist, create it
}
const lines = envContent.split('\n');
const newLines = [];
const settingsMap = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled.toString(),
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew.toString(),
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting.toString(),
'NOTIFICATION_ENABLED': settings.notificationEnabled.toString(),
'APPRISE_URLS': JSON.stringify(settings.appriseUrls || []),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
};
const existingKeys = new Set();
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines and comments
if (!trimmedLine || trimmedLine.startsWith('#')) {
newLines.push(line);
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
// Line doesn't contain '=', keep as is
newLines.push(line);
continue;
}
const key = trimmedLine.substring(0, equalIndex).trim();
if (key && key in settingsMap) {
// Replace existing setting
// @ts-ignore - Dynamic property access is safe here
newLines.push(`${key}=${settingsMap[key]}`);
existingKeys.add(key);
} else {
// Keep other settings as is
newLines.push(line);
}
}
// Add any missing settings
for (const [key, value] of Object.entries(settingsMap)) {
if (!existingKeys.has(key)) {
newLines.push(`${key}=${value}`);
}
}
writeFileSync(envPath, newLines.join('\n'));
console.log('Auto-sync settings saved successfully');
} catch (error) {
console.error('Error saving auto-sync settings:', error);
throw error;
}
}
/**
* Schedule auto-sync cron job
*/
scheduleAutoSync() {
this.stopAutoSync(); // Stop any existing job
const settings = this.loadSettings();
if (!settings.autoSyncEnabled) {
console.log('Auto-sync is disabled, not scheduling cron job');
this.isRunning = false; // Ensure we're completely stopped
return;
}
// Check if there's already a global autosync running
if (globalAutoSyncLock) {
console.log('Auto-sync is already running globally, not scheduling new cron job');
return;
}
let cronExpression;
if (settings.syncIntervalType === 'custom') {
cronExpression = settings.syncIntervalCron;
} else {
// Convert predefined intervals to cron expressions
const intervalMap = {
'15min': '*/15 * * * *',
'30min': '*/30 * * * *',
'1hour': '0 * * * *',
'6hours': '0 */6 * * *',
'12hours': '0 */12 * * *',
'24hours': '0 0 * * *'
};
// @ts-ignore - Dynamic key access is safe here
cronExpression = intervalMap[settings.syncIntervalPredefined] || '0 * * * *';
}
// Validate cron expression (5-field format for node-cron)
if (!cronValidator.isValidCron(cronExpression, { seconds: false })) {
console.error('Invalid cron expression:', cronExpression);
return;
}
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
this.cronJob = cron.schedule(cronExpression, async () => {
// Check global lock first
if (globalAutoSyncLock) {
console.log('Auto-sync already running globally, skipping cron execution...');
return;
}
if (this.isRunning) {
console.log('Auto-sync already running locally, skipping...');
return;
}
// Double-check that autosync is still enabled before executing
const currentSettings = this.loadSettings();
if (!currentSettings.autoSyncEnabled) {
console.log('Auto-sync has been disabled, stopping and destroying cron job');
this.stopAutoSync();
return;
}
// Additional check: if cronJob is null, it means it was stopped
if (!this.cronJob) {
console.log('Cron job was stopped, skipping execution');
return;
}
console.log('Starting scheduled auto-sync...');
await this.executeAutoSync();
}, {
scheduled: true,
timezone: 'UTC'
});
console.log('Auto-sync cron job scheduled successfully');
}
/**
* Stop auto-sync cron job
*/
stopAutoSync() {
if (this.cronJob) {
this.cronJob.stop();
this.cronJob.destroy();
this.cronJob = null;
this.isRunning = false;
console.log('Auto-sync cron job stopped and destroyed');
} else {
console.log('No active cron job to stop');
this.isRunning = false; // Ensure isRunning is false even if no cron job
}
}
/**
* Execute auto-sync process
*/
async executeAutoSync() {
// Check global lock first
if (globalAutoSyncLock) {
console.log('Auto-sync already running globally, skipping...');
return { success: false, message: 'Auto-sync already running globally' };
}
if (this.isRunning) {
console.log('Auto-sync already running locally, skipping...');
return { success: false, message: 'Auto-sync already running locally' };
}
// Set global lock
globalAutoSyncLock = true;
this.isRunning = true;
const startTime = new Date();
try {
console.log('Starting auto-sync execution...');
// Step 1: Sync JSON files
console.log('Syncing JSON files...');
const syncResult = await githubJsonService.syncJsonFiles();
if (!syncResult.success) {
throw new Error(`JSON sync failed: ${syncResult.message}`);
}
const results = {
jsonSync: syncResult,
newScripts: /** @type {any[]} */ ([]),
updatedScripts: /** @type {any[]} */ ([]),
errors: /** @type {string[]} */ ([])
};
// Step 2: Auto-download/update scripts if enabled
const settings = this.loadSettings();
if (settings.autoDownloadNew || settings.autoUpdateExisting) {
console.log('Processing synced JSON files for script downloads...');
// Only process scripts for files that were actually synced
if (syncResult.syncedFiles && syncResult.syncedFiles.length > 0) {
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
// Get scripts only for the synced files
const localScriptsService = await import('./localScripts.js');
const syncedScripts = [];
for (const filename of syncResult.syncedFiles) {
try {
// Extract slug from filename (remove .json extension)
const slug = filename.replace('.json', '');
const script = await localScriptsService.localScriptsService.getScriptBySlug(slug);
if (script) {
syncedScripts.push(script);
}
} catch (error) {
console.warn(`Error loading script from ${filename}:`, error);
}
}
console.log(`Found ${syncedScripts.length} scripts from synced JSON files`);
// Filter to only truly NEW scripts (not previously downloaded)
const newScripts = [];
const existingScripts = [];
for (const script of syncedScripts) {
try {
// Validate script object
if (!script || !script.slug) {
console.warn('Invalid script object found, skipping:', script);
continue;
}
const isDownloaded = await scriptDownloaderService.isScriptDownloaded(script);
if (!isDownloaded) {
newScripts.push(script);
} else {
existingScripts.push(script);
}
} catch (error) {
console.warn(`Error checking script ${script?.slug || 'unknown'}:`, error);
// Treat as new script if we can't check
if (script && script.slug) {
newScripts.push(script);
}
}
}
console.log(`Found ${newScripts.length} new scripts and ${existingScripts.length} existing scripts from synced files`);
// Download new scripts
if (settings.autoDownloadNew && newScripts.length > 0) {
console.log(`Auto-downloading ${newScripts.length} new scripts...`);
const downloaded = [];
const errors = [];
for (const script of newScripts) {
try {
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
downloaded.push(script); // Store full script object for category grouping
console.log(`Downloaded script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`${script.name || script.slug}: ${errorMsg}`);
console.error(`Failed to download script ${script.slug}:`, error);
}
}
results.newScripts = downloaded;
results.errors.push(...errors);
}
// Update existing scripts
if (settings.autoUpdateExisting && existingScripts.length > 0) {
console.log(`Auto-updating ${existingScripts.length} existing scripts...`);
const updated = [];
const errors = [];
for (const script of existingScripts) {
try {
// Always update existing scripts when auto-update is enabled
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
updated.push(script); // Store full script object for category grouping
console.log(`Updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`${script.name || script.slug}: ${errorMsg}`);
console.error(`Failed to update script ${script.slug}:`, error);
}
}
results.updatedScripts = updated;
results.errors.push(...errors);
}
} else {
console.log('No JSON files were synced, skipping script download/update');
}
} else {
console.log('Auto-download/update disabled, skipping script processing');
}
// Step 3: Send notifications if enabled
if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
console.log('Sending success notifications...');
await this.sendSyncNotification(results);
console.log('Success notifications sent');
}
// Step 4: Update last sync time and clear any previous errors
const lastSyncTime = this.safeToISOString(new Date());
const updatedSettings = {
...settings,
lastAutoSync: lastSyncTime,
lastAutoSyncError: '' // Clear any previous errors on successful sync
};
this.saveSettings(updatedSettings);
const duration = new Date().getTime() - startTime.getTime();
console.log(`Auto-sync completed successfully in ${duration}ms`);
return {
success: true,
message: 'Auto-sync completed successfully',
results,
duration
};
} catch (error) {
console.error('Auto-sync execution failed:', error);
// Check if it's a rate limit error
const isRateLimitError = error instanceof Error && error.name === 'RateLimitError';
const errorMessage = error instanceof Error ? error.message : String(error);
// Send error notification if enabled
const settings = this.loadSettings();
if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
try {
const notificationTitle = isRateLimitError ? 'Auto-Sync Rate Limited' : 'Auto-Sync Failed';
const notificationMessage = isRateLimitError
? `GitHub API rate limit exceeded. Please set a GITHUB_TOKEN in your .env file for higher rate limits. Error: ${errorMessage}`
: `Auto-sync failed with error: ${errorMessage}`;
await appriseService.sendNotification(
notificationTitle,
notificationMessage,
settings.appriseUrls || []
);
} catch (notifError) {
console.error('Failed to send error notification:', notifError);
}
}
// Store the error in settings for UI display
const errorSettings = this.loadSettings();
const errorToStore = isRateLimitError
? `GitHub API rate limit exceeded. Please set a GITHUB_TOKEN in your .env file for higher rate limits.`
: errorMessage;
const updatedErrorSettings = {
...errorSettings,
lastAutoSyncError: errorToStore,
lastAutoSyncErrorTime: this.safeToISOString(new Date())
};
this.saveSettings(updatedErrorSettings);
return {
success: false,
message: errorToStore,
error: errorMessage,
isRateLimitError
};
} finally {
this.isRunning = false;
globalAutoSyncLock = false; // Release global lock
}
}
/**
* Load categories from metadata.json
*/
loadCategories() {
try {
const metadataPath = join(process.cwd(), 'scripts', 'json', 'metadata.json');
const metadataContent = readFileSync(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
return metadata.categories || [];
} catch (error) {
console.error('Error loading categories:', error);
return [];
}
}
/**
* Group scripts by category
* @param {Array<any>} scripts - Array of script objects
* @param {Array<any>} categories - Array of category objects
*/
groupScriptsByCategory(scripts, categories) {
const categoryMap = new Map();
categories.forEach(cat => categoryMap.set(cat.id, cat.name));
const grouped = new Map();
scripts.forEach(script => {
// Validate script object
if (!script || !script.name) {
console.warn('Invalid script object in groupScriptsByCategory, skipping:', script);
return;
}
const scriptCategories = script.categories || [0]; // Default to Miscellaneous (id: 0)
scriptCategories.forEach((/** @type {number} */ catId) => {
const categoryName = categoryMap.get(catId) || 'Miscellaneous';
if (!grouped.has(categoryName)) {
grouped.set(categoryName, []);
}
grouped.get(categoryName).push(script.name);
});
});
return grouped;
}
/**
* Send notification about sync results
* @param {Object} results - Sync results object
*/
async sendSyncNotification(results) {
const settings = this.loadSettings();
if (!settings.notificationEnabled || !settings.appriseUrls?.length) {
return;
}
const title = 'ProxmoxVE-Local - Auto-Sync Completed';
let body = `Auto-sync completed successfully.\n\n`;
// Add JSON sync info
// @ts-ignore - Dynamic property access
if (results.jsonSync) {
// @ts-ignore - Dynamic property access
const syncedCount = results.jsonSync.count || 0;
// @ts-ignore - Dynamic property access
const syncedFiles = results.jsonSync.syncedFiles || [];
// Calculate up-to-date count (total files - synced files)
// We can't easily get total file count from the sync result, so just show synced count
if (syncedCount > 0) {
body += `JSON Files: ${syncedCount} synced\n`;
} else {
body += `JSON Files: All up-to-date\n`;
}
// @ts-ignore - Dynamic property access
if (results.jsonSync.errors?.length > 0) {
// @ts-ignore - Dynamic property access
body += `JSON Errors: ${results.jsonSync.errors.length}\n`;
}
body += '\n';
}
// Load categories for grouping
const categories = this.loadCategories();
// @ts-ignore - Dynamic property access
if (results.newScripts?.length > 0) {
// @ts-ignore - Dynamic property access
body += `New scripts downloaded: ${results.newScripts.length}\n`;
// Group new scripts by category
// @ts-ignore - Dynamic property access
const newScriptsGrouped = this.groupScriptsByCategory(results.newScripts, categories);
// Sort categories by name for consistent ordering
const sortedCategories = Array.from(newScriptsGrouped.keys()).sort();
sortedCategories.forEach(categoryName => {
const scripts = newScriptsGrouped.get(categoryName);
body += `\n**${categoryName}:**\n`;
scripts.forEach((/** @type {string} */ scriptName) => {
body += `${scriptName}\n`;
});
});
body += '\n';
}
// @ts-ignore - Dynamic property access
if (results.updatedScripts?.length > 0) {
// @ts-ignore - Dynamic property access
body += `Scripts updated: ${results.updatedScripts.length}\n`;
// Group updated scripts by category
// @ts-ignore - Dynamic property access
const updatedScriptsGrouped = this.groupScriptsByCategory(results.updatedScripts, categories);
// Sort categories by name for consistent ordering
const sortedCategories = Array.from(updatedScriptsGrouped.keys()).sort();
sortedCategories.forEach(categoryName => {
const scripts = updatedScriptsGrouped.get(categoryName);
body += `\n**${categoryName}:**\n`;
scripts.forEach((/** @type {string} */ scriptName) => {
body += `${scriptName}\n`;
});
});
body += '\n';
}
// @ts-ignore - Dynamic property access
if (results.errors?.length > 0) {
// @ts-ignore - Dynamic property access
body += `Script errors encountered: ${results.errors.length}\n`;
// @ts-ignore - Dynamic property access
body += `${results.errors.slice(0, 5).join('\n• ')}\n`;
// @ts-ignore - Dynamic property access
if (results.errors.length > 5) {
// @ts-ignore - Dynamic property access
body += `• ... and ${results.errors.length - 5} more errors\n`;
}
}
// @ts-ignore - Dynamic property access
if (results.newScripts?.length === 0 && results.updatedScripts?.length === 0 && results.errors?.length === 0) {
body += 'No script changes detected.';
}
try {
await appriseService.sendNotification(title, body, settings.appriseUrls);
console.log('Sync notification sent successfully');
} catch (error) {
console.error('Failed to send sync notification:', error);
}
}
/**
* Test notification
*/
async testNotification() {
const settings = this.loadSettings();
if (!settings.notificationEnabled || !settings.appriseUrls?.length) {
return {
success: false,
message: 'Notifications not enabled or no Apprise URLs configured'
};
}
try {
await appriseService.sendNotification(
'ProxmoxVE-Local - Test Notification',
'This is a test notification from PVE Scripts Local auto-sync feature.',
settings.appriseUrls
);
return {
success: true,
message: 'Test notification sent successfully'
};
} catch (error) {
return {
success: false,
message: `Failed to send test notification: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Get auto-sync status
*/
getStatus() {
return {
isRunning: this.isRunning,
hasCronJob: !!this.cronJob,
lastSync: this.loadSettings().lastAutoSync
};
}
}

View File

@@ -29,14 +29,24 @@ export class GitHubService {
}
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
},
});
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}

View File

@@ -0,0 +1,6 @@
// JavaScript wrapper for githubJsonService.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { githubJsonService } from './githubJsonService.ts';
export { githubJsonService };

View File

@@ -1,7 +1,7 @@
import { writeFile, mkdir } from 'fs/promises';
import { writeFile, mkdir, readdir } from 'fs/promises';
import { join } from 'path';
import { env } from '~/env.js';
import type { Script, ScriptCard, GitHubFile } from '~/types/script';
import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
export class GitHubJsonService {
private baseUrl: string | null = null;
@@ -41,14 +41,25 @@ export class GitHubJsonService {
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
this.initializeConfig();
const response = await fetch(`${this.baseUrl!}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
},
});
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${this.baseUrl!}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
@@ -59,8 +70,22 @@ export class GitHubJsonService {
this.initializeConfig();
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch!}/${filePath}`;
const response = await fetch(rawUrl);
const headers: HeadersInit = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available (for raw files, use token in URL or header)
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
@@ -185,48 +210,90 @@ export class GitHubJsonService {
}
}
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number }> {
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
try {
// Get all scripts from GitHub (1 API call + raw downloads)
const scripts = await this.getAllScripts();
console.log('Starting fast incremental JSON sync...');
// Save scripts to local directory
await this.saveScriptsLocally(scripts);
// Get file list from GitHub
console.log('Fetching file list from GitHub...');
const githubFiles = await this.getJsonFiles();
console.log(`Found ${githubFiles.length} JSON files in repository`);
// Get local files
const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} files in local directory`);
console.log(`Found ${localFiles.filter(f => f.endsWith('.json')).length} local JSON files`);
// Compare and find files that need syncing
const filesToSync = this.findFilesToSync(githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing`);
if (filesToSync.length === 0) {
return {
success: true,
message: 'All JSON files are up to date',
count: 0,
syncedFiles: []
};
}
// Download and save only the files that need syncing
const syncedFiles = await this.syncSpecificFiles(filesToSync);
return {
success: true,
message: `Successfully synced ${scripts.length} scripts from GitHub using 1 API call + raw downloads`,
count: scripts.length
message: `Successfully synced ${syncedFiles.length} JSON files from GitHub`,
count: syncedFiles.length,
syncedFiles
};
} catch (error) {
console.error('Error syncing JSON files:', error);
console.error('JSON sync failed:', error);
return {
success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0
count: 0,
syncedFiles: []
};
}
}
private async saveScriptsLocally(scripts: Script[]): Promise<void> {
private async getLocalJsonFiles(): Promise<string[]> {
this.initializeConfig();
try {
// Ensure the directory exists
await mkdir(this.localJsonDirectory!, { recursive: true });
// Save each script as a JSON file
for (const script of scripts) {
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory!, filename);
const content = JSON.stringify(script, null, 2);
await writeFile(filePath, content, 'utf-8');
}
} catch (error) {
console.error('Error saving scripts locally:', error);
throw new Error('Failed to save scripts locally');
const files = await readdir(this.localJsonDirectory!);
return files.filter(f => f.endsWith('.json'));
} catch {
return [];
}
}
private findFilesToSync(githubFiles: GitHubFile[], localFiles: string[]): GitHubFile[] {
const localFileSet = new Set(localFiles);
// Return only files that don't exist locally
return githubFiles.filter(ghFile => !localFileSet.has(ghFile.name));
}
private async syncSpecificFiles(filesToSync: GitHubFile[]): Promise<string[]> {
this.initializeConfig();
const syncedFiles: string[] = [];
await mkdir(this.localJsonDirectory!, { recursive: true });
for (const file of filesToSync) {
try {
const script = await this.downloadJsonFile(file.path);
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory!, filename);
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
syncedFiles.push(filename);
} catch (error) {
console.error(`Failed to sync ${file.name}:`, error);
}
}
return syncedFiles;
}
}
// Singleton instance

View File

@@ -0,0 +1,6 @@
// JavaScript wrapper for localScripts.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { localScriptsService } from './localScripts.ts';
export { localScriptsService };

View File

@@ -0,0 +1,544 @@
// Real JavaScript implementation for script downloading
import { join } from 'path';
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService {
constructor() {
this.scriptsDirectory = null;
this.repoUrl = null;
}
initializeConfig() {
if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts');
// Get REPO_URL from environment or use default
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
}
async ensureDirectoryExists(dirPath) {
try {
await mkdir(dirPath, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
}
async downloadFileFromGitHub(filePath) {
this.initializeConfig();
if (!this.repoUrl) {
throw new Error('REPO_URL environment variable is not set');
}
// Extract repo path from URL
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
const [, owner, repo] = match;
const url = `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePath}`;
console.log(`Downloading from GitHub: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
return response.text();
}
modifyScriptContent(content) {
// Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
const newPattern = 'SCRIPT_DIR="$(dirname "$0")" \nsource "$SCRIPT_DIR/../core/build.func"';
return content.replace(oldPattern, newPattern);
}
async loadScript(script) {
this.initializeConfig();
try {
const files = [];
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'vm'));
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Download from GitHub
console.log(`Downloading script file: ${scriptPath}`);
const content = await this.downloadFileFromGitHub(scriptPath);
// Determine target directory based on script path
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
finalTargetDir = targetDir;
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
}
files.push(`${finalTargetDir}/${fileName}`);
console.log(`Successfully downloaded: ${finalTargetDir}/${fileName}`);
}
}
}
}
// Only download install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
console.log(`Downloading install script: install/${installScriptName}`);
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
console.log(`Successfully downloaded: install/${installScriptName}`);
} catch (error) {
// Install script might not exist, that's okay
console.log(`Install script not found: install/${installScriptName}`);
}
}
// Download alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
console.log(`[${script.slug}] Checking for alpine variant:`, {
hasAlpineCtVariant,
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script }))
});
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
try {
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`);
const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`);
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
files.push(`install/${alpineInstallScriptName}`);
console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`);
} catch (error) {
// Alpine install script might not exist, that's okay
console.error(`[${script.slug}] Alpine install script not found or error: install/${alpineInstallScriptName}`, error);
if (error instanceof Error) {
console.error(`[${script.slug}] Error details:`, error.message, error.stack);
}
}
} else {
console.log(`[${script.slug}] No alpine CT variant found, skipping alpine install script download`);
}
return {
success: true,
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
files
};
} catch (error) {
console.error('Error loading script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to load script',
files: []
};
}
}
async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false;
// Check if ALL script files are downloaded
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
await import('fs/promises').then(fs => fs.readFile(filePath, 'utf8'));
// File exists, continue checking other methods
} catch {
// File doesn't exist, script is not fully downloaded
return false;
}
}
}
}
// All files exist, script is downloaded
return true;
}
async checkScriptExists(script) {
this.initializeConfig();
const files = [];
let ctExists = false;
let installExists = false;
try {
// Check scripts based on their install methods
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct'; // Default fallback
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
await access(filePath);
files.push(`${finalTargetDir}/${fileName}`);
// Set ctExists for all script types (CT, tools, vm) for UI consistency
if (scriptPath.startsWith('ct/') || scriptPath.startsWith('tools/') || scriptPath.startsWith('vm/')) {
ctExists = true;
}
} catch {
// File doesn't exist
}
}
}
}
}
// Check for install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const installPath = join(this.scriptsDirectory, 'install', installScriptName);
try {
await access(installPath);
files.push(`install/${installScriptName}`);
installExists = true;
} catch {
// Install script doesn't exist
}
}
// Check alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
try {
await access(alpineInstallPath);
files.push(`install/${alpineInstallScriptName}`);
installExists = true; // Mark as exists if alpine install script exists
} catch {
// File doesn't exist
}
}
return { ctExists, installExists, files };
} catch (error) {
console.error('Error checking script existence:', error);
return { ctExists: false, installExists: false, files: [] };
}
}
async deleteScript(script) {
this.initializeConfig();
const deletedFiles = [];
try {
// Get the list of files that exist for this script
const fileCheck = await this.checkScriptExists(script);
if (fileCheck.files.length === 0) {
return {
success: false,
message: 'No script files found to delete',
deletedFiles: []
};
}
// Delete all files
for (const filePath of fileCheck.files) {
try {
const fullPath = join(this.scriptsDirectory, filePath);
await unlink(fullPath);
deletedFiles.push(filePath);
} catch (error) {
// Log error but continue deleting other files
console.error(`Error deleting file ${filePath}:`, error);
}
}
if (deletedFiles.length === 0) {
return {
success: false,
message: 'Failed to delete any script files',
deletedFiles: []
};
}
return {
success: true,
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
deletedFiles
};
} catch (error) {
console.error('Error deleting script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles
};
}
}
async compareScriptContent(script) {
this.initializeConfig();
const differences = [];
let hasDifferences = false;
try {
// First check if any local files exist
const localFilesExist = await this.checkScriptExists(script);
if (!localFilesExist.ctExists && !localFilesExist.installExists) {
// No local files exist, so no comparison needed
return { hasDifferences: false, differences: [] };
}
// If we have local files, proceed with comparison
// Use Promise.all to run comparisons in parallel
const comparisonPromises = [];
// Compare scripts only if they exist locally
if (localFilesExist.ctExists && script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir;
let finalTargetDir;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else {
continue; // Skip unknown script types
}
comparisonPromises.push(
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
}
}
}
// Compare install script only if it exists locally
if (localFilesExist.installExists) {
const installScriptName = `${script.slug}-install.sh`;
const installScriptPath = `install/${installScriptName}`;
comparisonPromises.push(
this.compareSingleFile(installScriptPath, installScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
// Compare alpine install script if alpine variant exists (only for CT scripts)
const hasAlpineCtVariant = script.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
);
if (hasAlpineCtVariant) {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
const alpineInstallScriptPath = `install/${alpineInstallScriptName}`;
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath);
// Check if alpine install script exists locally
try {
await access(localAlpineInstallPath);
comparisonPromises.push(
this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
} catch {
// Alpine install script doesn't exist locally, skip comparison
}
}
// Wait for all comparisons to complete
await Promise.all(comparisonPromises);
return { hasDifferences, differences };
} catch (error) {
console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [] };
}
}
async compareSingleFile(remotePath, filePath) {
try {
const localPath = join(this.scriptsDirectory, filePath);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(remotePath);
// Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent;
if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
} else {
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
}
// Compare content
const hasDifferences = localContent !== modifiedRemoteContent;
return { hasDifferences, filePath };
} catch (error) {
console.error(`Error comparing file ${filePath}:`, error);
return { hasDifferences: false, filePath };
}
}
}
export const scriptDownloaderService = new ScriptDownloaderService();

View File

@@ -1,4 +1,4 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path';
import { env } from '~/env.js';
import type { Script } from '~/types/script';
@@ -167,6 +167,203 @@ export class ScriptDownloaderService {
}
}
/**
* Auto-download new scripts that haven't been downloaded yet
*/
async autoDownloadNewScripts(allScripts: Script[]): Promise<{ downloaded: string[]; errors: string[] }> {
this.initializeConfig();
const downloaded: string[] = [];
const errors: string[] = [];
for (const script of allScripts) {
try {
// Check if script is already downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (!isDownloaded) {
const result = await this.loadScript(script);
if (result.success) {
downloaded.push(script.name || script.slug);
console.log(`Auto-downloaded new script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-download script ${script.slug}:`, error);
}
}
return { downloaded, errors };
}
/**
* Auto-update existing scripts to newer versions
*/
async autoUpdateExistingScripts(allScripts: Script[]): Promise<{ updated: string[]; errors: string[] }> {
this.initializeConfig();
const updated: string[] = [];
const errors: string[] = [];
for (const script of allScripts) {
try {
// Check if script is downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (isDownloaded) {
// Check if update is needed by comparing content
const needsUpdate = await this.scriptNeedsUpdate(script);
if (needsUpdate) {
const result = await this.loadScript(script);
if (result.success) {
updated.push(script.name || script.slug);
console.log(`Auto-updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-update script ${script.slug}:`, error);
}
}
return { updated, errors };
}
/**
* Check if a script is already downloaded
*/
async isScriptDownloaded(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false;
// Check if ALL script files are downloaded
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
}
try {
await readFile(filePath, 'utf8');
// File exists, continue checking other methods
} catch {
// File doesn't exist, script is not fully downloaded
return false;
}
}
}
}
// All files exist, script is downloaded
return true;
}
/**
* Check if a script needs updating by comparing local and remote content
*/
private async scriptNeedsUpdate(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false;
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
}
try {
// Read local content
const localContent = await readFile(filePath, 'utf8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(scriptPath);
// Compare content (simple string comparison for now)
// In a more sophisticated implementation, you might want to compare
// file modification times or use content hashing
return localContent !== remoteContent;
} catch {
// If we can't read local or download remote, assume update needed
return true;
}
}
}
}
return false;
}
async checkScriptExists(script: Script): Promise<{ ctExists: boolean; installExists: boolean; files: string[] }> {
this.initializeConfig();
const files: string[] = [];
@@ -264,6 +461,57 @@ export class ScriptDownloaderService {
}
}
async deleteScript(script: Script): Promise<{ success: boolean; message: string; deletedFiles: string[] }> {
this.initializeConfig();
const deletedFiles: string[] = [];
try {
// Get the list of files that exist for this script
const fileCheck = await this.checkScriptExists(script);
if (fileCheck.files.length === 0) {
return {
success: false,
message: 'No script files found to delete',
deletedFiles: []
};
}
// Delete all files
for (const filePath of fileCheck.files) {
try {
const fullPath = join(this.scriptsDirectory!, filePath);
await unlink(fullPath);
deletedFiles.push(filePath);
} catch (error) {
// Log error but continue deleting other files
console.error(`Error deleting file ${filePath}:`, error);
}
}
if (deletedFiles.length === 0) {
return {
success: false,
message: 'Failed to delete any script files',
deletedFiles: []
};
}
return {
success: true,
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
deletedFiles
};
} catch (error) {
console.error('Error deleting script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete script',
deletedFiles
};
}
}
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
this.initializeConfig();
const differences: string[] = [];

View File

@@ -1,5 +1,6 @@
#!/bin/bash
#21.10.2025 - @michelroegl-brunner
# Enhanced update script for ProxmoxVE-Local
# Fetches latest release from GitHub and backs up data directory
@@ -298,6 +299,7 @@ clear_original_directory() {
# List of files/directories to preserve (already backed up)
local preserve_patterns=(
"data"
"data/*"
".env"
"*.log"
"update.log"
@@ -354,7 +356,7 @@ restore_backup_files() {
if [ -f ".env" ]; then
rm -f ".env"
fi
if mv "$BACKUP_DIR/.env" ".env"; then
if cp "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup"
else
log_error "Failed to restore .env file"
@@ -369,7 +371,7 @@ restore_backup_files() {
if [ -d "data" ]; then
rm -rf "data"
fi
if mv "$BACKUP_DIR/data" "data"; then
if cp -r "$BACKUP_DIR/data" "data"; then
log_success "Data directory restored from backup"
else
log_error "Failed to restore data directory"
@@ -396,7 +398,7 @@ restore_backup_files() {
rm -rf "$target_dir"
fi
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
if cp -r "$BACKUP_DIR/$backup_name" "$target_dir"; then
log_success "$target_dir directory restored from backup"
else
log_error "Failed to restore $target_dir directory"
@@ -412,6 +414,30 @@ restore_backup_files() {
fi
}
# Verify database was restored correctly
verify_database_restored() {
log "Verifying database was restored correctly..."
# Check for both possible database filenames
local db_file=""
if [ -f "data/database.sqlite" ]; then
db_file="data/database.sqlite"
elif [ -f "data/settings.db" ]; then
db_file="data/settings.db"
else
log_error "Database file not found after restore! (checked database.sqlite and settings.db)"
return 1
fi
local db_size=$(stat -f%z "$db_file" 2>/dev/null || stat -c%s "$db_file" 2>/dev/null)
if [ "$db_size" -eq 0 ]; then
log_warning "Database file is empty - will be recreated by Prisma migrations"
return 0 # Don't fail the update, let Prisma recreate the database
fi
log_success "Database verified (file: $db_file, size: $db_size bytes)"
}
# Ensure DATABASE_URL is set in .env file for Prisma
ensure_database_url() {
log "Ensuring DATABASE_URL is set in .env file..."
@@ -437,7 +463,7 @@ ensure_database_url() {
log "Adding DATABASE_URL to .env file..."
echo "" >> .env
echo "# Database" >> .env
echo "DATABASE_URL=\"file:./data/database.sqlite\"" >> .env
echo "DATABASE_URL=\"file:./data/settings.db\"" >> .env
log_success "DATABASE_URL added to .env file"
}
@@ -465,15 +491,15 @@ stop_application() {
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try to find the application directory
app_dir=$(find /root -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
# Change to production application directory
app_dir="/opt/ProxmoxVE-Local"
if [ -d "$app_dir" ] && [ -f "$app_dir/server.js" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
return 1
}
else
log_error "Could not find application directory"
log_error "Production application directory not found: $app_dir"
return 1
fi
fi
@@ -555,7 +581,7 @@ update_files() {
local should_exclude=false
for pattern in "${exclude_patterns[@]}"; do
if [[ "$rel_path" == $pattern ]]; then
if [[ "$rel_path" == $pattern ]] || [[ "$rel_path" == $pattern/* ]]; then
should_exclude=true
break
fi
@@ -595,6 +621,7 @@ update_files() {
log_success "Application files updated successfully ($files_copied files)"
}
# Install dependencies and build
install_and_build() {
log "Installing dependencies..."
@@ -650,6 +677,15 @@ install_and_build() {
fi
log_success "Prisma client generated successfully"
# Check if Prisma migrations exist and are compatible
if [ -d "prisma/migrations" ]; then
log "Existing migration history detected"
local migration_count=$(find prisma/migrations -type d -mindepth 1 | wc -l)
log "Found $migration_count existing migrations"
else
log_warning "No existing migration history found - this may be a fresh install"
fi
# Run Prisma migrations
log "Running Prisma migrations..."
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
@@ -706,11 +742,16 @@ start_application() {
fi
else
log_error "Failed to enable/start service, falling back to npm start"
start_with_npm
if ! start_with_npm; then
log_error "Failed to start application with npm"
return 1
fi
fi
else
log "Service was not running before update or no service exists, starting with npm..."
start_with_npm
if ! start_with_npm; then
return 1
fi
fi
}
@@ -834,23 +875,15 @@ main() {
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try multiple common locations:
for search_path in /opt /root /home /usr/local; do
if [ -d "$search_path" ]; then
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
break
fi
fi
done
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
# Use production application directory
app_dir="/opt/ProxmoxVE-Local"
if [ -d "$app_dir" ] && [ -f "$app_dir/server.js" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
exit 1
}
else
log_error "Could not find application directory"
log_error "Production application directory not found: $app_dir"
exit 1
fi
fi
@@ -894,6 +927,12 @@ main() {
# Restore .env and data directory before building
restore_backup_files
# Verify database was restored correctly
if ! verify_database_restored; then
log_error "Database verification failed, rolling back..."
rollback
fi
# Ensure DATABASE_URL is set for Prisma
ensure_database_url
@@ -903,12 +942,17 @@ main() {
rollback
fi
# Cleanup
# Start the application
if ! start_application; then
log_error "Failed to start application after update"
rollback
fi
# Cleanup only after successful start
rm -rf "$source_dir"
rm -rf "/tmp/pve-update-$$"
# Start the application
start_application
rm -rf "$BACKUP_DIR"
log "Backup directory cleaned up"
log_success "Update completed successfully!"
}