Compare commits

...

52 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
54 changed files with 5834 additions and 3214 deletions

View File

@@ -27,3 +27,12 @@ AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false AUTH_SETUP_COMPLETED=false
JWT_SECRET= JWT_SECRET=
DATABASE_URL="file:/opt/ProxmoxVE-Local/data/settings.db" 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

@@ -1 +1 @@
0.4.7 0.4.10

180
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pve-scripts-local", "name": "pve-scripts-local",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@prisma/client": "^6.17.1", "@prisma/client": "^6.18.0",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
@@ -29,7 +29,7 @@
"cron-validator": "^1.2.0", "cron-validator": "^1.2.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.548.0",
"next": "^15.5.6", "next": "^15.5.6",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
@@ -48,7 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.15", "@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -59,7 +59,7 @@
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2", "@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/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"eslint": "^9.38.0", "eslint": "^9.38.0",
@@ -2042,9 +2042,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.17.1", "version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz",
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==", "integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -2660,9 +2660,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38", "version": "1.0.0-beta.43",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
"integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3053,56 +3053,49 @@
} }
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==", "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3", "enhanced-resolve": "^5.18.3",
"jiti": "^2.6.0", "jiti": "^2.6.1",
"lightningcss": "1.30.2", "lightningcss": "1.30.2",
"magic-string": "^0.30.19", "magic-string": "^0.30.19",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "4.1.15" "tailwindcss": "4.1.16"
} }
}, },
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/oxide": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==", "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.15", "@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.15", "@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.15", "@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.15", "@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.15", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.15", "@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.15", "@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.15", "@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.15", "@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.15", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.15" "@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==", "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3117,9 +3110,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==", "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3134,9 +3127,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==", "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3151,9 +3144,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==", "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3168,9 +3161,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==", "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -3185,9 +3178,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==", "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3202,9 +3195,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==", "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3219,9 +3212,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==", "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3236,9 +3229,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==", "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3253,9 +3246,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==", "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
"@emnapi/core", "@emnapi/core",
@@ -3343,9 +3336,9 @@
"optional": true "optional": true
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==", "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3360,9 +3353,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==", "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3377,26 +3370,19 @@
} }
}, },
"node_modules/@tailwindcss/postcss": { "node_modules/@tailwindcss/postcss": {
"version": "4.1.15", "version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz",
"integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==", "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.15", "@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.15", "@tailwindcss/oxide": "4.1.16",
"postcss": "^8.4.41", "postcss": "^8.4.41",
"tailwindcss": "4.1.15" "tailwindcss": "4.1.16"
} }
}, },
"node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/typography": { "node_modules/@tailwindcss/typography": {
"version": "0.5.19", "version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
@@ -4384,18 +4370,18 @@
] ]
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.0.4", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
"integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.28.4", "@babel/core": "^7.28.4",
"@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.38", "@rolldown/pluginutils": "1.0.0-beta.43",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0" "react-refresh": "^0.18.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@@ -8675,9 +8661,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.546.0", "version": "0.548.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz",
"integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -10645,9 +10631,9 @@
} }
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.17.1", "@prisma/client": "^6.18.0",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
@@ -43,7 +43,7 @@
"cron-validator": "^1.2.0", "cron-validator": "^1.2.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.548.0",
"next": "^15.5.6", "next": "^15.5.6",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
@@ -62,7 +62,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.15", "@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -73,7 +73,7 @@
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2", "@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/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"eslint": "^9.38.0", "eslint": "^9.38.0",

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"documentation": "https://docs.bunkerweb.io/latest/", "documentation": "https://docs.bunkerweb.io/latest/",
"website": "https://www.bunkerweb.io/", "website": "https://www.bunkerweb.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/bunkerweb.webp", "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.", "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": [ "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, "ram": 2048,
"hdd": 10, "hdd": 10,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -12,7 +12,7 @@
"documentation": "https://github.com/HydroshieldMKII/Guardian/blob/main/README.md", "documentation": "https://github.com/HydroshieldMKII/Guardian/blob/main/README.md",
"config_path": "/opt/guardian/.env", "config_path": "/opt/guardian/.env",
"website": "https://github.com/HydroshieldMKII/Guardian", "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.", "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": [ "install_methods": [
{ {

View File

@@ -21,7 +21,7 @@
"resources": { "resources": {
"cpu": 2, "cpu": 2,
"ram": 2048, "ram": 2048,
"hdd": 8, "hdd": 16,
"os": "ubuntu", "os": "ubuntu",
"version": "24.04" "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, "ram": 2048,
"hdd": 8, "hdd": 8,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@
"ram": 1024, "ram": 1024,
"hdd": 4, "hdd": 4,
"os": "debian", "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, "ram": 8192,
"hdd": 25, "hdd": 25,
"os": "debian", "os": "debian",
"version": "13" "version": "12"
} }
} }
], ],

View File

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

View File

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

View File

@@ -23,7 +23,7 @@
"ram": 512, "ram": 512,
"hdd": 2, "hdd": 2,
"os": "debian", "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, "ram": 2048,
"hdd": 5, "hdd": 5,
"os": "Debian", "os": "Debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ export function FilterBar({
}: FilterBarProps) { }: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false); const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => { const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates }); onFiltersChange({ ...filters, ...updates });
@@ -98,44 +99,17 @@ export function FilterBar({
{!isLoadingFilters && ( {!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3> <h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" /> <div className="flex items-center gap-2">
</div> <ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
)}
{/* 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 <Button
onClick={() => updateFilters({ searchQuery: "" })} onClick={() => setIsMinimized(!isMinimized)}
variant="ghost" variant="ghost"
size="icon" 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 <svg
className="h-5 w-5" className={`h-4 w-4 transition-transform ${isMinimized ? "" : "rotate-180"}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -144,16 +118,68 @@ export function FilterBar({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M6 18L18 6M6 6l12 12" d="M5 15l7-7 7 7"
/> />
</svg> </svg>
</Button> </Button>
)} </div>
</div> </div>
</div> )}
{/* Filter Buttons */} {/* Filter Content - Conditionally rendered based on minimized state */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3"> {!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 */} {/* Updateable Filter */}
<Button <Button
onClick={() => { onClick={() => {
@@ -431,6 +457,8 @@ export function FilterBar({
</Button> </Button>
)} )}
</div> </div>
</>
)}
{/* Click outside to close dropdowns */} {/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && ( {(isTypeDropdownOpen || isSortDropdownOpen) && (

View File

@@ -8,6 +8,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
import { useTheme } from './ThemeProvider'; import { useTheme } from './ThemeProvider';
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { useAuth } from './AuthProvider';
interface GeneralSettingsModalProps { interface GeneralSettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -17,7 +18,9 @@ interface GeneralSettingsModalProps {
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) { export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general'); const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
const [githubToken, setGithubToken] = useState(''); const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false); const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null); const [savedFilters, setSavedFilters] = useState<any>(null);
@@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [authHasCredentials, setAuthHasCredentials] = useState(false); const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false); const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [sessionDurationDays, setSessionDurationDays] = useState(7);
// Auto-sync state // Auto-sync state
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
@@ -46,6 +50,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [appriseUrls, setAppriseUrls] = useState<string[]>([]); const [appriseUrls, setAppriseUrls] = useState<string[]>([]);
const [appriseUrlsText, setAppriseUrlsText] = useState(''); const [appriseUrlsText, setAppriseUrlsText] = useState('');
const [lastAutoSync, setLastAutoSync] = useState(''); const [lastAutoSync, setLastAutoSync] = useState('');
const [lastAutoSyncError, setLastAutoSyncError] = useState<string | null>(null);
const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState<string | null>(null);
const [cronValidationError, setCronValidationError] = useState(''); const [cronValidationError, setCronValidationError] = useState('');
// Load existing settings when modal opens // Load existing settings when modal opens
@@ -212,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
try { try {
const response = await fetch('/api/settings/auth-credentials'); const response = await fetch('/api/settings/auth-credentials');
if (response.ok) { 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 ?? ''); setAuthUsername(data.username ?? '');
setAuthEnabled(data.enabled ?? false); setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false); setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false); setAuthSetupCompleted(data.setupCompleted ?? false);
setSessionDurationDays(data.sessionDurationDays ?? 7);
} }
} catch (error) { } catch (error) {
console.error('Error loading auth credentials:', error); console.error('Error loading auth credentials:', error);
@@ -225,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 () => { const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) { if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' }); setMessage({ type: 'error', text: 'Passwords do not match' });
@@ -263,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) => { const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true); setAuthLoading(true);
setMessage(null); setMessage(null);
@@ -311,6 +411,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
setAppriseUrls(settings.appriseUrls ?? []); setAppriseUrls(settings.appriseUrls ?? []);
setAppriseUrlsText((settings.appriseUrls ?? []).join('\n')); setAppriseUrlsText((settings.appriseUrls ?? []).join('\n'));
setLastAutoSync(settings.lastAutoSync ?? ''); setLastAutoSync(settings.lastAutoSync ?? '');
setLastAutoSyncError(settings.lastAutoSyncError ?? null);
setLastAutoSyncErrorTime(settings.lastAutoSyncErrorTime ?? null);
} }
} }
} catch (error) { } catch (error) {
@@ -323,30 +425,6 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
setMessage(null); setMessage(null);
try { try {
// Validate cron expression if custom
if (syncIntervalType === 'custom' && syncIntervalCron) {
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) {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save auto-sync settings' });
return;
}
}
const response = await fetch('/api/settings/auto-sync', { const response = await fetch('/api/settings/auto-sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -361,7 +439,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
appriseUrls: appriseUrls appriseUrls: appriseUrls
}) })
}); });
if (response.ok) { if (response.ok) {
setMessage({ type: 'success', text: 'Auto-sync settings saved successfully!' }); setMessage({ type: 'success', text: 'Auto-sync settings saved successfully!' });
setTimeout(() => setMessage(null), 3000); setTimeout(() => setMessage(null), 3000);
@@ -682,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
{activeTab === 'auth' && ( {activeTab === 'auth' && (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
<div> <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"> <p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure authentication to secure access to your application. Configure authentication to secure access to your application.
</p> </p>
@@ -719,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </div>
</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"> <div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4> <h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
@@ -824,7 +967,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </div>
<Toggle <Toggle
checked={autoSyncEnabled} checked={autoSyncEnabled}
onCheckedChange={setAutoSyncEnabled} 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} disabled={isSaving}
/> />
</div> </div>
@@ -1016,6 +1193,25 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div> </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"> <div className="flex gap-2">
<Button <Button
onClick={triggerManualSync} onClick={triggerManualSync}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { HelpCircle, Server, Settings, RefreshCw, Clock, 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'; import { useRegisterModal } from './modal/ModalStackProvider';
interface HelpModalProps { interface HelpModalProps {
@@ -11,7 +11,7 @@ interface HelpModalProps {
initialSection?: string; initialSection?: string;
} }
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'auto-sync' | '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) { export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose }); useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -22,6 +22,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
const sections = [ const sections = [
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server }, { id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings }, { 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: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock }, { id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package }, { id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
@@ -126,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li> Token is stored securely and only used for API calls</li> <li> Token is stored securely and only used for API calls</li>
</ul> </ul>
</div> </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"> <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"> <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> </p>
<ul className="text-sm text-muted-foreground space-y-1"> <ul className="text-sm text-muted-foreground space-y-1">
<li> Set up username and password for app access</li> <li> Set up username and password for app access</li>
<li> Enable/disable authentication as needed</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> </ul>
</div> </div>
</div> </div>

View File

@@ -935,6 +935,18 @@ export function InstalledScriptsTab() {
> >
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'} {showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button> </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 <Button
onClick={() => { onClick={() => {
// Trigger status check by calling the mutation directly // 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 { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer"; import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal"; import { ExecutionModeModal } from "./ExecutionModeModal";
import { ConfirmationModal } from "./ConfirmationModal";
import { ScriptVersionModal } from "./ScriptVersionModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge"; import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider'; import { useRegisterModal } from './modal/ModalStackProvider';
@@ -37,6 +39,10 @@ export function ScriptDetailModal({
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null); const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
const [textViewerOpen, setTextViewerOpen] = useState(false); const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = 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 // Check if script files exist locally
const { 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; if (!isOpen || !script) return null;
const handleImageError = () => { const handleImageError = () => {
@@ -105,16 +136,43 @@ export function ScriptDetailModal({
const handleInstallScript = () => { const handleInstallScript = () => {
if (!script) return; 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); setExecutionModeOpen(true);
}; };
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => { const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
if (!script || !onInstallScript) return; 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( const scriptMethod = script.install_methods?.find(
(method) => method.type === versionType && method.script,
) || script.install_methods?.find(
(method) => method.script, (method) => method.script,
); );
if (scriptMethod?.script) { if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`; const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name; const scriptName = script.name;
@@ -130,6 +188,19 @@ export function ScriptDetailModal({
setTextViewerOpen(true); 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 ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50" 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 />} {script.privileged && <PrivilegedBadge />}
</div> </div>
</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> </div>
{/* Close Button */} {/* 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> </div>
{/* Content */} {/* Content */}
@@ -708,11 +829,22 @@ export function ScriptDetailModal({
?.script?.split("/") ?.script?.split("/")
.pop() ?? `${script.slug}.sh` .pop() ?? `${script.slug}.sh`
} }
script={script}
isOpen={textViewerOpen} isOpen={textViewerOpen}
onClose={() => setTextViewerOpen(false)} onClose={() => setTextViewerOpen(false)}
/> />
)} )}
{/* Version Selection Modal */}
{script && (
<ScriptVersionModal
script={script}
isOpen={versionModalOpen}
onClose={() => setVersionModalOpen(false)}
onSelectVersion={handleVersionSelect}
/>
)}
{/* Execution Mode Modal */} {/* Execution Mode Modal */}
{script && ( {script && (
<ExecutionModeModal <ExecutionModeModal
@@ -722,6 +854,20 @@ export function ScriptDetailModal({
onExecute={handleExecuteScript} 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> </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 [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true); const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery(); const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -535,7 +536,30 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}; };
const handleDownloadAllFiltered = () => { 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) { if (slugsToDownload.length > 0) {
void downloadScriptsIndividually(slugsToDownload); 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 // All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug); setSelectedSlug(scriptCard.slug);
setIsModalOpen(true); setIsModalOpen(true);
@@ -666,8 +690,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
/> />
{/* Newest Scripts Carousel - Always show when there are newest scripts */} {/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
{newestScripts.length > 0 && ( {newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
<div className="mb-8"> <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="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"> <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" /> <Clock className="h-6 w-6 text-primary" />
Newest Scripts Newest Scripts
</h2> </h2>
<span className="text-sm text-muted-foreground"> <div className="flex items-center gap-2">
{newestScripts.length} recently added <span className="text-sm text-muted-foreground">
</span> {newestScripts.length} recently added
</div> </span>
<Button
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent"> onClick={() => setIsNewestMinimized(!isNewestMinimized)}
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}> variant="ghost"
{newestScripts.map((script, index) => { size="icon"
if (!script || typeof script !== 'object') { className="h-8 w-8 text-muted-foreground hover:text-foreground"
return null; title={isNewestMinimized ? "Expand newest scripts" : "Minimize newest scripts"}
} >
<svg
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
fill="none"
return ( stroke="currentColor"
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80"> viewBox="0 0 24 24"
<div className="relative"> >
<ScriptCard <path
script={script} strokeLinecap="round"
onClick={handleCardClick} strokeLinejoin="round"
isSelected={selectedSlugs.has(script.slug ?? '')} strokeWidth={2}
onToggleSelect={toggleScriptSelection} d="M5 15l7-7 7 7"
/> />
{/* NEW badge */} </svg>
<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"> </Button>
NEW
</div>
</div>
</div>
);
})}
</div> </div>
</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>
</div> </div>
)} )}

View File

@@ -53,6 +53,50 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
void loadColorCodingSetting(); 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 validateForm = (): boolean => {
const newErrors: Partial<Record<keyof CreateServerData, string>> = {}; const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
@@ -61,12 +105,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
} }
if (!formData.ip.trim()) { if (!formData.ip.trim()) {
newErrors.ip = 'IP address is required'; newErrors.ip = 'Server address is required';
} else { } else {
// Basic IP validation if (!validateServerAddress(formData.ip)) {
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]?)$/; newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
if (!ipRegex.test(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address';
} }
} }
@@ -221,7 +263,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
<div> <div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1"> <label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address * Host/IP Address *
</label> </label>
<input <input
type="text" 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 ${ 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' 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>} {errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div> </div>

View File

@@ -4,77 +4,156 @@ import { useState, useEffect, useCallback } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { Script } from '../../types/script';
interface TextViewerProps { interface TextViewerProps {
scriptName: string; scriptName: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
script?: Script | null;
} }
interface ScriptContent { interface ScriptContent {
ctScript?: string; ctScript?: string;
installScript?: 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 [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct'); const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
// Extract slug from script name (remove .sh extension) // 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 () => { const loadScriptContent = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
// Try to load from different possible locations // Build fetch requests for default version
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([ const requests: Promise<Response>[] = [];
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}` } }))}`), // Default CT script
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`), requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`), 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` } }))}`) 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 = {}; 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 } } } }; const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (ctData.result?.data?.json?.success) { if (ctData.result?.data?.json?.success) {
content.ctScript = ctData.result.data.json.content; 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 } } } }; const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (toolsData.result?.data?.json?.success) { if (toolsData.result?.data?.json?.success) {
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too 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 } } } }; const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vmData.result?.data?.json?.success) { if (vmData.result?.data?.json?.success) {
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too 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 } } } }; const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vwData.result?.data?.json?.success) { if (vwData.result?.data?.json?.success) {
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too 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 } } } }; const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (installData.result?.data?.json?.success) { if (installData.result?.data?.json?.success) {
content.installScript = installData.result.data.json.content; 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); setScriptContent(content);
} catch (err) { } catch (err) {
@@ -82,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [scriptName, slug]); }, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
useEffect(() => { useEffect(() => {
if (isOpen && scriptName) { 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"> <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 */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border"> <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"> <h2 className="text-2xl font-bold text-foreground">
Script Viewer: {scriptName} Script Viewer: {defaultScriptName}
</h2> </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"> <div className="flex space-x-2">
<Button <Button
variant={activeTab === 'ct' ? 'outline' : 'ghost'} variant={activeTab === 'ct' ? 'outline' : 'ghost'}
@@ -151,44 +249,87 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{activeTab === 'ct' && scriptContent.ctScript ? ( {activeTab === 'ct' && (
<SyntaxHighlighter selectedVersion === 'default' && scriptContent.ctScript ? (
language="bash" <SyntaxHighlighter
style={tomorrow} language="bash"
customStyle={{ style={tomorrow}
margin: 0, customStyle={{
padding: '1rem', margin: 0,
fontSize: '14px', padding: '1rem',
lineHeight: '1.5', fontSize: '14px',
minHeight: '100%' lineHeight: '1.5',
}} minHeight: '100%'
showLineNumbers={true} }}
wrapLines={true} showLineNumbers={true}
> wrapLines={true}
{scriptContent.ctScript} >
</SyntaxHighlighter> {scriptContent.ctScript}
) : activeTab === 'install' && scriptContent.installScript ? ( </SyntaxHighlighter>
<SyntaxHighlighter ) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
language="bash" <SyntaxHighlighter
style={tomorrow} language="bash"
customStyle={{ style={tomorrow}
margin: 0, customStyle={{
padding: '1rem', margin: 0,
fontSize: '14px', padding: '1rem',
lineHeight: '1.5', fontSize: '14px',
minHeight: '100%' lineHeight: '1.5',
}} minHeight: '100%'
showLineNumbers={true} }}
wrapLines={true} showLineNumbers={true}
> wrapLines={true}
{scriptContent.installScript} >
</SyntaxHighlighter> {scriptContent.alpineCtScript}
) : ( </SyntaxHighlighter>
<div className="flex items-center justify-center h-full"> ) : (
<div className="text-lg text-muted-foreground"> <div className="flex items-center justify-center h-full">
{activeTab === 'ct' ? 'CT script not found' : 'Install script not found'} <div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
</div>
</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> </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({ const response = NextResponse.json({
success: true, success: true,
@@ -46,12 +47,12 @@ export async function POST(request: NextRequest) {
username username
}); });
// Set httpOnly cookie // Set httpOnly cookie with configured duration
response.cookies.set('auth-token', token, { response.cookies.set('auth-token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 days maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/', 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({ return NextResponse.json({
success: true, success: true,
username: decoded.username, username: decoded.username,
authenticated: true authenticated: true,
expirationTime,
timeUntilExpiration
}); });
} catch (error) { } catch (error) {
console.error('Error verifying token:', error); console.error('Error verifying token:', error);

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } 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 fs from 'fs';
import path from 'path'; import path from 'path';
import { withApiLogging } from '../../../../server/logging/withApiLogging'; import { withApiLogging } from '../../../../server/logging/withApiLogging';
@@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() {
enabled: authConfig.enabled, enabled: authConfig.enabled,
hasCredentials: authConfig.hasCredentials, hasCredentials: authConfig.hasCredentials,
setupCompleted: authConfig.setupCompleted, setupCompleted: authConfig.setupCompleted,
sessionDurationDays: authConfig.sessionDurationDays,
}); });
} catch { } catch {
// Error handled by withApiLogging // 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) { export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
try { try {
const { enabled } = await request.json() as { enabled: boolean }; const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number };
if (typeof enabled !== 'boolean') { if (body.enabled !== undefined) {
return NextResponse.json( const { enabled } = body;
{ error: 'Enabled flag must be a boolean' },
{ status: 400 } 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');
} }
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH if (enabled) {
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, ''); // When enabling, just update the flag
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, ''); updateAuthEnabled(enabled);
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
} else { } 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 return NextResponse.json({
envContent = envContent.replace(/\n\n+/g, '\n'); success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
fs.writeFileSync(envPath, envContent); });
} }
return NextResponse.json({ if (body.sessionDurationDays !== undefined) {
success: true, const { sessionDurationDays } = body;
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
}); 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 { } catch {
// Error handled by withApiLogging // Error handled by withApiLogging
return NextResponse.json( return NextResponse.json(

View File

@@ -64,14 +64,12 @@ export async function POST(request: NextRequest) {
// Validate custom cron expression // Validate custom cron expression
if (settings.syncIntervalType === 'custom') { if (settings.syncIntervalType === 'custom') {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string') { if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
return NextResponse.json( // Fallback to predefined if custom is selected but no cron expression
{ error: 'Custom cron expression is required when syncIntervalType is "custom"' }, settings.syncIntervalType = 'predefined';
{ status: 400 } settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
); settings.syncIntervalCron = '';
} } else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid cron expression' }, { error: 'Invalid cron expression' },
{ status: 400 } { status: 400 }
@@ -138,7 +136,9 @@ export async function POST(request: NextRequest) {
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false', 'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false', 'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'), 'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync || '' 'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
}; };
// Update or add each setting // Update or add each setting
@@ -160,15 +160,28 @@ export async function POST(request: NextRequest) {
// Reschedule auto-sync service with new settings // Reschedule auto-sync service with new settings
try { try {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js'); const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
const autoSyncService = new AutoSyncService(); 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) { if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync(); autoSyncService.scheduleAutoSync();
console.log('Auto-sync rescheduled with new settings');
} else { } else {
autoSyncService.stopAutoSync(); autoSyncService.stopAutoSync();
console.log('Auto-sync stopped'); // 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) { } catch (error) {
console.error('Error rescheduling auto-sync service:', error); console.error('Error rescheduling auto-sync service:', error);
@@ -204,7 +217,9 @@ export async function GET() {
autoUpdateExisting: false, autoUpdateExisting: false,
notificationEnabled: false, notificationEnabled: false,
appriseUrls: [], appriseUrls: [],
lastAutoSync: '' lastAutoSync: '',
lastAutoSyncError: null,
lastAutoSyncErrorTime: null
} }
}); });
} }
@@ -228,7 +243,9 @@ export async function GET() {
return []; return [];
} }
})(), })(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '' 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 }); return NextResponse.json({ settings });

View File

@@ -16,10 +16,12 @@ import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon'; import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal'; import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer'; 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 { api } from '~/trpc/react';
import { useAuth } from './_components/AuthProvider';
export default function Home() { export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => { const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') { 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"> <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> <span className="break-words">PVE Scripts Management</span>
</h1> </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 /> <ThemeToggle />
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
const SALT_ROUNDS = 10; 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 // Cache for JWT secret to avoid multiple file reads
let jwtSecretCache: string | null = null; let jwtSecretCache: string | null = null;
@@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise<b
/** /**
* Generate a JWT token * Generate a JWT token
*/ */
export function generateToken(username: string): string { export function generateToken(username: string, durationDays?: number): string {
const secret = getJwtSecret(); 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 * 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 { try {
const secret = getJwtSecret(); 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; return decoded;
} catch { } catch {
return null; return null;
@@ -93,6 +106,7 @@ export function getAuthConfig(): {
enabled: boolean; enabled: boolean;
hasCredentials: boolean; hasCredentials: boolean;
setupCompleted: boolean; setupCompleted: boolean;
sessionDurationDays: number;
} { } {
const envPath = path.join(process.cwd(), '.env'); const envPath = path.join(process.cwd(), '.env');
@@ -103,6 +117,7 @@ export function getAuthConfig(): {
enabled: false, enabled: false,
hasCredentials: false, hasCredentials: false,
setupCompleted: false, setupCompleted: false,
sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS,
}; };
} }
@@ -128,6 +143,13 @@ export function getAuthConfig(): {
const setupCompletedMatch = setupCompletedRegex.exec(envContent); const setupCompletedMatch = setupCompletedRegex.exec(envContent);
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false; 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); const hasCredentials = !!(username && passwordHash);
return { return {
@@ -136,6 +158,7 @@ export function getAuthConfig(): {
enabled, enabled,
hasCredentials, hasCredentials,
setupCompleted, setupCompleted,
sessionDurationDays,
}; };
} }
@@ -238,3 +261,30 @@ export function updateAuthEnabled(enabled: boolean): void {
fs.writeFileSync(envPath, envContent); 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) { 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 { try {
const scriptData = script as any; const server = allServers.find((s: any) => s.id === serverId);
const server = allServers.find((s: any) => s.id === scriptData.server_id);
if (!server) { if (!server) {
await db.deleteInstalledScript(Number(scriptData.id)); // Server doesn't exist, delete all scripts for this server
deletedScripts.push(String(scriptData.script_name)); for (const scriptData of serverScripts) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
}
continue; continue;
} }
// Test SSH connection // Test SSH connection
const connectionTest = await sshService.testSSHConnection(server as Server); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
console.warn(`cleanupOrphanedScripts: SSH connection failed for server ${String((server as any).name)}, skipping ${serverScripts.length} scripts`);
continue; continue;
} }
// Check if the container config file still exists // Get all existing containers from pct list (more reliable than checking config files)
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`; const listCommand = 'pct list';
let listOutput = '';
// Await full command completion to avoid early false negatives const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
const containerExists = await new Promise<boolean>((resolve) => { const timeout = setTimeout(() => {
let combinedOutput = ''; console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
let resolved = false; resolve(new Set()); // Treat timeout as no containers found
}, 20000);
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);
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
checkCommand, listCommand,
(data: string) => { (data: string) => {
combinedOutput += data; listOutput += data;
}, },
(error: string) => { (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) => { (_exitCode: number) => {
clearTimeout(timer); clearTimeout(timeout);
finish();
// 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) { // Check each script against the list of existing containers
await db.deleteInstalledScript(Number(scriptData.id)); for (const scriptData of serverScripts) {
deletedScripts.push(String(scriptData.script_name)); try {
} else { 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) { } catch (error) {
console.error(`Error checking script ${(script as any).script_name}:`, error); console.error(`cleanupOrphanedScripts: Error processing server ${serverId}:`, error);
} }
} }

View File

@@ -115,6 +115,18 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() })) .input(z.object({ slug: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
try { 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); const script = await githubJsonService.getScriptBySlug(input.slug);
if (!script) { if (!script) {
return { return {
@@ -125,6 +137,7 @@ export const scriptsRouter = createTRPCRouter({
} }
return { success: true, script }; return { success: true, script };
} catch (error) { } catch (error) {
console.error('Error in getScriptBySlug:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to fetch script', error: error instanceof Error ? error.message : 'Failed to fetch script',
@@ -350,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 // Compare local and remote script content
compareScriptContent: publicProcedure compareScriptContent: publicProcedure
.input(z.object({ slug: z.string() })) .input(z.object({ slug: z.string() }))
@@ -490,14 +531,29 @@ export const scriptsRouter = createTRPCRouter({
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const autoSyncService = new AutoSyncService(); // 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); autoSyncService.saveSettings(input);
// Reschedule auto-sync if enabled // Reschedule auto-sync if enabled
if (input.autoSyncEnabled) { if (input.autoSyncEnabled) {
autoSyncService.scheduleAutoSync(); autoSyncService.scheduleAutoSync();
console.log('Auto-sync rescheduled with new settings');
} else { } else {
autoSyncService.stopAutoSync(); 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' }; return { success: true, message: 'Auto-sync settings saved successfully' };

View File

@@ -1,21 +1,31 @@
import { AutoSyncService } from '../services/autoSyncService.js'; import { AutoSyncService } from '../services/autoSyncService.js';
let autoSyncService = null; let autoSyncService = null;
let isInitialized = false;
/** /**
* Initialize auto-sync service and schedule cron job if enabled * Initialize auto-sync service and schedule cron job if enabled
*/ */
export function initializeAutoSync() { export function initializeAutoSync() {
if (isInitialized) {
console.log('Auto-sync service already initialized, skipping...');
return;
}
try { try {
console.log('Initializing auto-sync service...'); console.log('Initializing auto-sync service...');
autoSyncService = new AutoSyncService(); autoSyncService = new AutoSyncService();
isInitialized = true;
console.log('AutoSyncService instance created');
// Load settings and schedule if enabled // Load settings and schedule if enabled
const settings = autoSyncService.loadSettings(); const settings = autoSyncService.loadSettings();
console.log('Settings loaded:', settings);
if (settings.autoSyncEnabled) { if (settings.autoSyncEnabled) {
console.log('Auto-sync is enabled, scheduling cron job...'); console.log('Auto-sync is enabled, scheduling cron job...');
autoSyncService.scheduleAutoSync(); autoSyncService.scheduleAutoSync();
console.log('Cron job scheduled');
} else { } else {
console.log('Auto-sync is disabled'); console.log('Auto-sync is disabled');
} }
@@ -23,6 +33,7 @@ export function initializeAutoSync() {
console.log('Auto-sync service initialized successfully'); console.log('Auto-sync service initialized successfully');
} catch (error) { } catch (error) {
console.error('Failed to initialize auto-sync service:', error); console.error('Failed to initialize auto-sync service:', error);
console.error('Error stack:', error.stack);
} }
} }
@@ -35,6 +46,7 @@ export function stopAutoSync() {
console.log('Stopping auto-sync service...'); console.log('Stopping auto-sync service...');
autoSyncService.stopAutoSync(); autoSyncService.stopAutoSync();
autoSyncService = null; autoSyncService = null;
isInitialized = false;
console.log('Auto-sync service stopped'); console.log('Auto-sync service stopped');
} }
} catch (error) { } catch (error) {
@@ -49,6 +61,13 @@ export function getAutoSyncService() {
return autoSyncService; return autoSyncService;
} }
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service) {
autoSyncService = service;
}
/** /**
* Graceful shutdown handler * Graceful shutdown handler
*/ */

View File

@@ -49,6 +49,13 @@ export function getAutoSyncService(): AutoSyncService | null {
return autoSyncService; return autoSyncService;
} }
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service: AutoSyncService | null): void {
autoSyncService = service;
}
/** /**
* Graceful shutdown handler * Graceful shutdown handler
*/ */

View File

@@ -25,6 +25,25 @@ export class ScriptManager {
// Initialize lazily to avoid accessing env vars during module load // 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() { private initializeConfig() {
if (this.scriptsDir === null) { if (this.scriptsDir === null) {
// Handle both absolute and relative paths for testing // Handle both absolute and relative paths for testing
@@ -63,7 +82,7 @@ export class ScriptManager {
path: filePath, path: filePath,
extension, extension,
size: stats.size, size: stats.size,
lastModified: stats.mtime, lastModified: this.safeMtime(stats.mtime),
executable executable
}); });
} }
@@ -125,7 +144,7 @@ export class ScriptManager {
path: filePath, path: filePath,
extension, extension,
size: stats.size, size: stats.size,
lastModified: stats.mtime, lastModified: this.safeMtime(stats.mtime),
executable, executable,
logo, logo,
slug slug
@@ -212,7 +231,7 @@ export class ScriptManager {
path: filePath, path: filePath,
extension, extension,
size: stats.size, size: stats.size,
lastModified: stats.mtime, lastModified: this.safeMtime(stats.mtime),
executable, executable,
logo, logo,
slug slug

View File

@@ -6,12 +6,34 @@ import { readFile, writeFile, readFileSync, writeFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import cronValidator from 'cron-validator'; import cronValidator from 'cron-validator';
// Global lock to prevent multiple autosync instances from running simultaneously
let globalAutoSyncLock = false;
export class AutoSyncService { export class AutoSyncService {
constructor() { constructor() {
this.cronJob = null; this.cronJob = null;
this.isRunning = false; 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 * Load auto-sync settings from .env file
*/ */
@@ -20,6 +42,19 @@ export class AutoSyncService {
const envPath = join(process.cwd(), '.env'); const envPath = join(process.cwd(), '.env');
const envContent = readFileSync(envPath, 'utf8'); 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 = { const settings = {
autoSyncEnabled: false, autoSyncEnabled: false,
syncIntervalType: 'predefined', syncIntervalType: 'predefined',
@@ -29,7 +64,9 @@ export class AutoSyncService {
autoUpdateExisting: false, autoUpdateExisting: false,
notificationEnabled: false, notificationEnabled: false,
appriseUrls: [], appriseUrls: [],
lastAutoSync: '' lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
}; };
const lines = envContent.split('\n'); const lines = envContent.split('\n');
@@ -74,6 +111,12 @@ export class AutoSyncService {
case 'LAST_AUTO_SYNC': case 'LAST_AUTO_SYNC':
settings.lastAutoSync = value; settings.lastAutoSync = value;
break; break;
case 'LAST_AUTO_SYNC_ERROR':
settings.lastAutoSyncError = value;
break;
case 'LAST_AUTO_SYNC_ERROR_TIME':
settings.lastAutoSyncErrorTime = value;
break;
} }
} }
} }
@@ -90,7 +133,9 @@ export class AutoSyncService {
autoUpdateExisting: false, autoUpdateExisting: false,
notificationEnabled: false, notificationEnabled: false,
appriseUrls: [], appriseUrls: [],
lastAutoSync: '' lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
}; };
} }
} }
@@ -107,6 +152,8 @@ export class AutoSyncService {
* @param {boolean} settings.notificationEnabled * @param {boolean} settings.notificationEnabled
* @param {Array<string>} [settings.appriseUrls] * @param {Array<string>} [settings.appriseUrls]
* @param {string} [settings.lastAutoSync] * @param {string} [settings.lastAutoSync]
* @param {string} [settings.lastAutoSyncError]
* @param {string} [settings.lastAutoSyncErrorTime]
*/ */
saveSettings(settings) { saveSettings(settings) {
try { try {
@@ -130,19 +177,37 @@ export class AutoSyncService {
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting.toString(), 'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting.toString(),
'NOTIFICATION_ENABLED': settings.notificationEnabled.toString(), 'NOTIFICATION_ENABLED': settings.notificationEnabled.toString(),
'APPRISE_URLS': JSON.stringify(settings.appriseUrls || []), 'APPRISE_URLS': JSON.stringify(settings.appriseUrls || []),
'LAST_AUTO_SYNC': settings.lastAutoSync || '' 'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
}; };
const existingKeys = new Set(); const existingKeys = new Set();
for (const line of lines) { for (const line of lines) {
const [key] = line.split('='); const trimmedLine = line.trim();
const trimmedKey = key?.trim();
if (trimmedKey && trimmedKey in settingsMap) { // Skip empty lines and comments
// @ts-ignore - Dynamic key access is safe here if (!trimmedLine || trimmedLine.startsWith('#')) {
newLines.push(`${trimmedKey}=${settingsMap[trimmedKey]}`); newLines.push(line);
existingKeys.add(trimmedKey); continue;
} else if (trimmedKey && !(trimmedKey in settingsMap)) { }
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); newLines.push(line);
} }
} }
@@ -170,6 +235,14 @@ export class AutoSyncService {
const settings = this.loadSettings(); const settings = this.loadSettings();
if (!settings.autoSyncEnabled) { 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; return;
} }
@@ -200,8 +273,28 @@ export class AutoSyncService {
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`); console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
this.cronJob = cron.schedule(cronExpression, async () => { 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) { if (this.isRunning) {
console.log('Auto-sync already running, skipping...'); 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; return;
} }
@@ -221,8 +314,13 @@ export class AutoSyncService {
stopAutoSync() { stopAutoSync() {
if (this.cronJob) { if (this.cronJob) {
this.cronJob.stop(); this.cronJob.stop();
this.cronJob.destroy();
this.cronJob = null; this.cronJob = null;
console.log('Auto-sync cron job stopped'); 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
} }
} }
@@ -230,11 +328,19 @@ export class AutoSyncService {
* Execute auto-sync process * Execute auto-sync process
*/ */
async executeAutoSync() { async executeAutoSync() {
if (this.isRunning) { // Check global lock first
console.log('Auto-sync already running, skipping...'); if (globalAutoSyncLock) {
return { success: false, message: 'Auto-sync already running' }; 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; this.isRunning = true;
const startTime = new Date(); const startTime = new Date();
@@ -251,56 +357,120 @@ export class AutoSyncService {
const results = { const results = {
jsonSync: syncResult, jsonSync: syncResult,
newScripts: [], newScripts: /** @type {any[]} */ ([]),
updatedScripts: [], updatedScripts: /** @type {any[]} */ ([]),
errors: [] errors: /** @type {string[]} */ ([])
}; };
// Step 2: Auto-download/update scripts if enabled // Step 2: Auto-download/update scripts if enabled
const settings = this.loadSettings(); const settings = this.loadSettings();
if (settings.autoDownloadNew || settings.autoUpdateExisting) { if (settings.autoDownloadNew || settings.autoUpdateExisting) {
console.log('Processing synced JSON files for script downloads...');
// Only process scripts for files that were actually synced // Only process scripts for files that were actually synced
// @ts-ignore - syncedFiles exists in the JavaScript version
if (syncResult.syncedFiles && syncResult.syncedFiles.length > 0) { if (syncResult.syncedFiles && syncResult.syncedFiles.length > 0) {
// @ts-ignore - syncedFiles exists in the JavaScript version console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for new scripts...`);
// Get all scripts from synced files // Get scripts only for the synced files
// @ts-ignore - syncedFiles exists in the JavaScript version const localScriptsService = await import('./localScripts.js');
const allSyncedScripts = await githubJsonService.getScriptsForFiles(syncResult.syncedFiles); const syncedScripts = [];
// Initialize script downloader service for (const filename of syncResult.syncedFiles) {
// @ts-ignore - initializeConfig is public in the JS version try {
scriptDownloaderService.initializeConfig(); // Extract slug from filename (remove .json extension)
const slug = filename.replace('.json', '');
// Filter to only truly NEW scripts (not previously downloaded) const script = await localScriptsService.localScriptsService.getScriptBySlug(slug);
const newScripts = []; if (script) {
for (const script of allSyncedScripts) { syncedScripts.push(script);
const isDownloaded = await scriptDownloaderService.isScriptDownloaded(script); }
if (!isDownloaded) { } catch (error) {
newScripts.push(script); console.warn(`Error loading script from ${filename}:`, error);
} }
} }
console.log(`Found ${newScripts.length} new scripts out of ${allSyncedScripts.length} total scripts`); console.log(`Found ${syncedScripts.length} scripts from synced JSON files`);
if (settings.autoDownloadNew && newScripts.length > 0) { // Filter to only truly NEW scripts (not previously downloaded)
console.log(`Auto-downloading ${newScripts.length} new scripts...`); const newScripts = [];
const downloadResult = await scriptDownloaderService.autoDownloadNewScripts(newScripts); const existingScripts = [];
// @ts-ignore - Type assertion needed for dynamic assignment
results.newScripts = downloadResult.downloaded; for (const script of syncedScripts) {
// @ts-ignore - Type assertion needed for dynamic assignment try {
results.errors.push(...downloadResult.errors); // 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);
}
}
} }
if (settings.autoUpdateExisting) { console.log(`Found ${newScripts.length} new scripts and ${existingScripts.length} existing scripts from synced files`);
console.log('Auto-updating existing scripts from synced files...');
const updateResult = await scriptDownloaderService.autoUpdateExistingScripts(allSyncedScripts); // Download new scripts
// @ts-ignore - Type assertion needed for dynamic assignment if (settings.autoDownloadNew && newScripts.length > 0) {
results.updatedScripts = updateResult.updated; console.log(`Auto-downloading ${newScripts.length} new scripts...`);
// @ts-ignore - Type assertion needed for dynamic assignment const downloaded = [];
results.errors.push(...updateResult.errors); 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 { } else {
console.log('No JSON files were synced, skipping script download/update'); console.log('No JSON files were synced, skipping script download/update');
@@ -310,14 +480,19 @@ export class AutoSyncService {
} }
// Step 3: Send notifications if enabled // Step 3: Send notifications if enabled
if (settings.notificationEnabled && settings.appriseUrls?.length > 0) { if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
console.log('Sending notifications...'); console.log('Sending success notifications...');
await this.sendSyncNotification(results); await this.sendSyncNotification(results);
console.log('Success notifications sent');
} }
// Step 4: Update last sync time // Step 4: Update last sync time and clear any previous errors
const lastSyncTime = new Date().toISOString(); const lastSyncTime = this.safeToISOString(new Date());
const updatedSettings = { ...settings, lastAutoSync: lastSyncTime }; const updatedSettings = {
...settings,
lastAutoSync: lastSyncTime,
lastAutoSyncError: '' // Clear any previous errors on successful sync
};
this.saveSettings(updatedSettings); this.saveSettings(updatedSettings);
const duration = new Date().getTime() - startTime.getTime(); const duration = new Date().getTime() - startTime.getTime();
@@ -333,27 +508,51 @@ export class AutoSyncService {
} catch (error) { } catch (error) {
console.error('Auto-sync execution failed:', 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 // Send error notification if enabled
const settings = this.loadSettings(); const settings = this.loadSettings();
if (settings.notificationEnabled && settings.appriseUrls?.length > 0) { if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
try { 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( await appriseService.sendNotification(
'Auto-Sync Failed', notificationTitle,
`Auto-sync failed with error: ${error instanceof Error ? error.message : String(error)}`, notificationMessage,
settings.appriseUrls settings.appriseUrls || []
); );
} catch (notifError) { } catch (notifError) {
console.error('Failed to send error notification:', 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 { return {
success: false, success: false,
message: error instanceof Error ? error.message : String(error), message: errorToStore,
error: error instanceof Error ? error.message : String(error) error: errorMessage,
isRateLimitError
}; };
} finally { } finally {
this.isRunning = false; this.isRunning = false;
globalAutoSyncLock = false; // Release global lock
} }
} }
@@ -384,6 +583,12 @@ export class AutoSyncService {
const grouped = new Map(); const grouped = new Map();
scripts.forEach(script => { 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) const scriptCategories = script.categories || [0]; // Default to Miscellaneous (id: 0)
scriptCategories.forEach((/** @type {number} */ catId) => { scriptCategories.forEach((/** @type {number} */ catId) => {
const categoryName = categoryMap.get(catId) || 'Miscellaneous'; const categoryName = categoryMap.get(catId) || 'Miscellaneous';
@@ -415,7 +620,18 @@ export class AutoSyncService {
// @ts-ignore - Dynamic property access // @ts-ignore - Dynamic property access
if (results.jsonSync) { if (results.jsonSync) {
// @ts-ignore - Dynamic property access // @ts-ignore - Dynamic property access
body += `JSON Files: ${results.jsonSync.syncedCount} synced, ${results.jsonSync.skippedCount} up-to-date\n`; 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 // @ts-ignore - Dynamic property access
if (results.jsonSync.errors?.length > 0) { if (results.jsonSync.errors?.length > 0) {
// @ts-ignore - Dynamic property access // @ts-ignore - Dynamic property access

View File

@@ -29,14 +29,24 @@ export class GitHubService {
} }
private async fetchFromGitHub<T>(endpoint: string): Promise<T> { private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, { const headers: HeadersInit = {
headers: { 'Accept': 'application/vnd.github.v3+json',
'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'PVEScripts-Local/1.0',
'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.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}`); throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} }

View File

@@ -1,276 +1,6 @@
import { writeFile, mkdir } from 'fs/promises'; // JavaScript wrapper for githubJsonService.ts
import { readFileSync, readdirSync, statSync, utimesSync } from 'fs'; // This allows the JavaScript autoSyncService.js to import the TypeScript service
import { join } from 'path';
import { Buffer } from 'buffer';
export class GitHubJsonService { import { githubJsonService } from './githubJsonService.ts';
constructor() {
this.baseUrl = null;
this.repoUrl = null;
this.branch = null;
this.jsonFolder = null;
this.localJsonDirectory = null;
this.scriptCache = new Map();
}
initializeConfig() { export { githubJsonService };
if (this.repoUrl === null) {
// Get environment variables
this.repoUrl = process.env.REPO_URL || "";
this.branch = process.env.REPO_BRANCH || "main";
this.jsonFolder = process.env.JSON_FOLDER || "scripts";
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
// Only validate GitHub URL if it's provided
if (this.repoUrl) {
// Extract owner and repo from the URL
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`);
}
const [, owner, repo] = urlMatch;
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
} else {
// Set a dummy base URL if no REPO_URL is provided
this.baseUrl = "";
}
}
}
async fetchFromGitHub(endpoint) {
this.initializeConfig();
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
...(process.env.GITHUB_TOKEN && { 'Authorization': `token ${process.env.GITHUB_TOKEN}` })
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async syncJsonFiles() {
try {
this.initializeConfig();
if (!this.baseUrl) {
return {
success: false,
message: 'No GitHub repository configured'
};
}
console.log('Starting fast incremental JSON sync...');
// Ensure local directory exists
await mkdir(this.localJsonDirectory, { recursive: true });
// Step 1: Get file list from GitHub (single API call)
console.log('Fetching file list from GitHub...');
const files = await this.fetchFromGitHub(`/contents/${this.jsonFolder}?ref=${this.branch}`);
if (!Array.isArray(files)) {
throw new Error('Invalid response from GitHub API');
}
const jsonFiles = files.filter(file => file.name.endsWith('.json'));
console.log(`Found ${jsonFiles.length} JSON files in repository`);
// Step 2: Get local file list (fast local operation)
const localFiles = new Map();
try {
console.log(`Looking for local files in: ${this.localJsonDirectory}`);
const localFileList = readdirSync(this.localJsonDirectory);
console.log(`Found ${localFileList.length} files in local directory`);
for (const fileName of localFileList) {
if (fileName.endsWith('.json')) {
const filePath = join(this.localJsonDirectory, fileName);
const stats = statSync(filePath);
localFiles.set(fileName, {
mtime: stats.mtime,
size: stats.size
});
}
}
} catch (error) {
console.log('Error reading local directory:', error.message);
console.log('Directory path:', this.localJsonDirectory);
console.log('No local files found, will download all');
}
console.log(`Found ${localFiles.size} local JSON files`);
// Step 3: Compare and identify files that need syncing
const filesToSync = [];
let skippedCount = 0;
for (const file of jsonFiles) {
const localFile = localFiles.get(file.name);
if (!localFile) {
// File doesn't exist locally
filesToSync.push(file);
console.log(`Missing: ${file.name}`);
} else {
// Compare modification times and sizes
const localMtime = new Date(localFile.mtime);
const remoteMtime = new Date(file.updated_at);
const localSize = localFile.size;
const remoteSize = file.size;
// Sync if remote is newer OR sizes are different (content changed)
if (localMtime < remoteMtime || localSize !== remoteSize) {
filesToSync.push(file);
console.log(`Changed: ${file.name} (${localMtime.toISOString()} -> ${remoteMtime.toISOString()})`);
} else {
skippedCount++;
console.log(`Up-to-date: ${file.name}`);
}
}
}
console.log(`Files to sync: ${filesToSync.length}, Up-to-date: ${skippedCount}`);
// Step 4: Download only the files that need syncing
let syncedCount = 0;
const errors = [];
const syncedFiles = [];
// Process files in batches to avoid overwhelming the API
const batchSize = 10;
for (let i = 0; i < filesToSync.length; i += batchSize) {
const batch = filesToSync.slice(i, i + batchSize);
// Process batch in parallel
const promises = batch.map(async (file) => {
try {
const content = await this.fetchFromGitHub(`/contents/${file.path}?ref=${this.branch}`);
if (content.content) {
// Decode base64 content
const fileContent = Buffer.from(content.content, 'base64').toString('utf-8');
// Write to local file
const localPath = join(this.localJsonDirectory, file.name);
await writeFile(localPath, fileContent, 'utf-8');
// Update file modification time to match remote
const remoteMtime = new Date(file.updated_at);
utimesSync(localPath, remoteMtime, remoteMtime);
syncedCount++;
syncedFiles.push(file.name);
console.log(`Synced: ${file.name}`);
}
} catch (error) {
console.error(`Failed to sync ${file.name}:`, error.message);
errors.push(`${file.name}: ${error.message}`);
}
});
await Promise.all(promises);
// Small delay between batches to be nice to the API
if (i + batchSize < filesToSync.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log(`JSON sync completed. Synced ${syncedCount} files, skipped ${skippedCount} files.`);
return {
success: true,
message: `Successfully synced ${syncedCount} JSON files (${skippedCount} up-to-date)`,
syncedCount,
skippedCount,
syncedFiles,
errors
};
} catch (error) {
console.error('JSON sync failed:', error);
return {
success: false,
message: error.message,
error: error.message
};
}
}
async getAllScripts() {
try {
this.initializeConfig();
if (!this.localJsonDirectory) {
return [];
}
const scripts = [];
// Read all JSON files from local directory
const files = readdirSync(this.localJsonDirectory);
const jsonFiles = files.filter(file => file.endsWith('.json'));
for (const file of jsonFiles) {
try {
const filePath = join(this.localJsonDirectory, file);
const content = readFileSync(filePath, 'utf-8');
const script = JSON.parse(content);
if (script && typeof script === 'object') {
scripts.push(script);
}
} catch (error) {
console.error(`Failed to parse ${file}:`, error.message);
}
}
return scripts;
} catch (error) {
console.error('Failed to get all scripts:', error);
return [];
}
}
/**
* Get scripts only for specific JSON files that were synced
*/
async getScriptsForFiles(syncedFiles) {
try {
this.initializeConfig();
if (!this.localJsonDirectory || !syncedFiles || syncedFiles.length === 0) {
return [];
}
const scripts = [];
for (const fileName of syncedFiles) {
try {
const filePath = join(this.localJsonDirectory, fileName);
const content = readFileSync(filePath, 'utf-8');
const script = JSON.parse(content);
if (script && typeof script === 'object') {
scripts.push(script);
}
} catch (error) {
console.error(`Failed to parse ${fileName}:`, error.message);
}
}
return scripts;
} catch (error) {
console.error('Failed to get scripts for synced files:', error);
return [];
}
}
}
export const githubJsonService = new 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 { join } from 'path';
import { env } from '~/env.js'; import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '~/types/script'; import type { Script, ScriptCard, GitHubFile } from '../../types/script';
export class GitHubJsonService { export class GitHubJsonService {
private baseUrl: string | null = null; private baseUrl: string | null = null;
@@ -41,14 +41,25 @@ export class GitHubJsonService {
private async fetchFromGitHub<T>(endpoint: string): Promise<T> { private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
this.initializeConfig(); this.initializeConfig();
const response = await fetch(`${this.baseUrl!}${endpoint}`, {
headers: { const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0', '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.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}`); throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} }
@@ -59,8 +70,22 @@ export class GitHubJsonService {
this.initializeConfig(); this.initializeConfig();
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch!}/${filePath}`; 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.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}`); 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 { try {
// Get all scripts from GitHub (1 API call + raw downloads) console.log('Starting fast incremental JSON sync...');
const scripts = await this.getAllScripts();
// Save scripts to local directory // Get file list from GitHub
await this.saveScriptsLocally(scripts); 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 { return {
success: true, success: true,
message: `Successfully synced ${scripts.length} scripts from GitHub using 1 API call + raw downloads`, message: `Successfully synced ${syncedFiles.length} JSON files from GitHub`,
count: scripts.length count: syncedFiles.length,
syncedFiles
}; };
} catch (error) { } catch (error) {
console.error('Error syncing JSON files:', error); console.error('JSON sync failed:', error);
return { return {
success: false, success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`, 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(); this.initializeConfig();
try { try {
// Ensure the directory exists const files = await readdir(this.localJsonDirectory!);
await mkdir(this.localJsonDirectory!, { recursive: true }); return files.filter(f => f.endsWith('.json'));
} catch {
// Save each script as a JSON file return [];
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');
} }
} }
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 // 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

@@ -1,14 +1,18 @@
import { writeFile, readFile, mkdir } from 'fs/promises'; // Real JavaScript implementation for script downloading
import { join } from 'path'; import { join } from 'path';
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService { export class ScriptDownloaderService {
constructor() { constructor() {
this.scriptsDirectory = null; this.scriptsDirectory = null;
this.repoUrl = null;
} }
initializeConfig() { initializeConfig() {
if (this.scriptsDirectory === null) { if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts'); 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';
} }
} }
@@ -23,19 +27,35 @@ export class ScriptDownloaderService {
} }
async downloadFileFromGitHub(filePath) { async downloadFileFromGitHub(filePath) {
// This is a simplified version - in a real implementation, this.initializeConfig();
// you would fetch the file content from GitHub if (!this.repoUrl) {
// For now, we'll return a placeholder throw new Error('REPO_URL environment variable is not set');
return `#!/bin/bash }
# Downloaded script: ${filePath}
# This is a placeholder - implement actual GitHub file download // Extract repo path from URL
echo "Script downloaded: ${filePath}" 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) { modifyScriptContent(content) {
// Modify script content for CT scripts if needed // Replace the build.func source line
return content; 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) { async loadScript(script) {
@@ -57,6 +77,7 @@ echo "Script downloaded: ${filePath}"
if (fileName) { if (fileName) {
// Download from GitHub // Download from GitHub
console.log(`Downloading script file: ${scriptPath}`);
const content = await this.downloadFileFromGitHub(scriptPath); const content = await this.downloadFileFromGitHub(scriptPath);
// Determine target directory based on script path // Determine target directory based on script path
@@ -91,16 +112,6 @@ echo "Script downloaded: ${filePath}"
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir)); await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8'); await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
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 { } else {
// Handle other script types (fallback to ct directory) // Handle other script types (fallback to ct directory)
targetDir = 'ct'; targetDir = 'ct';
@@ -111,6 +122,7 @@ echo "Script downloaded: ${filePath}"
} }
files.push(`${finalTargetDir}/${fileName}`); files.push(`${finalTargetDir}/${fileName}`);
console.log(`Successfully downloaded: ${finalTargetDir}/${fileName}`);
} }
} }
} }
@@ -121,15 +133,47 @@ echo "Script downloaded: ${filePath}"
if (hasCtScript) { if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`; const installScriptName = `${script.slug}-install.sh`;
try { try {
console.log(`Downloading install script: install/${installScriptName}`);
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`); const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName); const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8'); await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`); files.push(`install/${installScriptName}`);
} catch { console.log(`Successfully downloaded: install/${installScriptName}`);
} catch (error) {
// Install script might not exist, that's okay // 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 { return {
success: true, success: true,
message: `Successfully loaded ${files.length} script(s) for ${script.name}`, message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
@@ -145,78 +189,6 @@ echo "Script downloaded: ${filePath}"
} }
} }
/**
* Auto-download new scripts that haven't been downloaded yet
*/
async autoDownloadNewScripts(allScripts) {
this.initializeConfig();
const downloaded = [];
const errors = [];
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); // Return full script object instead of just name
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) {
this.initializeConfig();
const updated = [];
const errors = [];
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); // Return full script object instead of just name
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) { async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false; if (!script.install_methods?.length) return false;
@@ -248,12 +220,6 @@ echo "Script downloaded: ${filePath}"
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : ''; const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir; finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName); 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 { } else {
targetDir = 'ct'; targetDir = 'ct';
finalTargetDir = targetDir; finalTargetDir = targetDir;
@@ -261,7 +227,7 @@ echo "Script downloaded: ${filePath}"
} }
try { try {
await readFile(filePath, 'utf8'); await import('fs/promises').then(fs => fs.readFile(filePath, 'utf8'));
// File exists, continue checking other methods // File exists, continue checking other methods
} catch { } catch {
// File doesn't exist, script is not fully downloaded // File doesn't exist, script is not fully downloaded
@@ -275,71 +241,303 @@ echo "Script downloaded: ${filePath}"
return true; return true;
} }
/** async checkScriptExists(script) {
* Check if a script needs updating by comparing local and remote content this.initializeConfig();
*/ const files = [];
async scriptNeedsUpdate(script) { let ctExists = false;
if (!script.install_methods?.length) return false; let installExists = false;
for (const method of script.install_methods) { try {
if (method.script) { // Check scripts based on their install methods
const scriptPath = method.script; if (script.install_methods?.length) {
const fileName = scriptPath.split('/').pop(); for (const method of script.install_methods) {
if (method.script) {
if (fileName) { const scriptPath = method.script;
// Determine target directory based on script path const fileName = scriptPath.split('/').pop();
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 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 if (fileName) {
const remoteContent = await this.downloadFileFromGitHub(scriptPath); let targetDir;
let finalTargetDir;
// Compare content (simple string comparison for now) let filePath;
// In a more sophisticated implementation, you might want to compare
// file modification times or use content hashing if (scriptPath.startsWith('ct/')) {
return localContent !== remoteContent; targetDir = 'ct';
} catch { finalTargetDir = targetDir;
// If we can't read local or download remote, assume update needed filePath = join(this.scriptsDirectory, targetDir, fileName);
return true; } 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
}
}
} }
} }
} }
}
return false; // 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 };
}
} }
} }

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 { join } from 'path';
import { env } from '~/env.js'; import { env } from '~/env.js';
import type { Script } from '~/types/script'; import type { Script } from '~/types/script';
@@ -461,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[] }> { async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
this.initializeConfig(); this.initializeConfig();
const differences: string[] = []; const differences: string[] = [];