import Service from '#models/service' import { BaseSeeder } from '@adonisjs/lucid/seeders' import { ModelAttributes } from '@adonisjs/lucid/types/model' import env from '#start/env' import { SERVICE_NAMES } from '../../constants/service_names.js' import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js' type ServiceSeedRecord = Omit< ModelAttributes, | 'created_at' | 'updated_at' | 'id' | 'available_update_version' | 'update_checked_at' | 'metadata' | 'is_user_modified' | 'custom_url' | 'auto_update_enabled' | 'available_update_first_seen_at' | 'auto_update_consecutive_failures' | 'auto_update_disabled_reason' > & { metadata?: string | null } export default class ServiceSeeder extends BaseSeeder { // Use environment variable with fallback to production default private static NOMAD_STORAGE_ABS_PATH = env.get( 'NOMAD_STORAGE_PATH', '/opt/project-nomad/storage' ) private static DEFAULT_SERVICES: ServiceSeedRecord[] = [ // ── Core / original services ────────────────────────────────────────────── { service_name: SERVICE_NAMES.KIWIX, friendly_name: 'Information Library', powered_by: 'Kiwix', display_order: 1, description: 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias', icon: 'IconBooks', container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1', source_repo: 'https://github.com/kiwix/kiwix-tools', container_command: KIWIX_LIBRARY_CMD, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/zim:/data`], PortBindings: { '8080/tcp': [{ HostPort: '8090' }] }, }, ExposedPorts: { '8080/tcp': {} }, }), ui_location: '8090', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'education', depends_on: null, }, { service_name: SERVICE_NAMES.QDRANT, friendly_name: 'Qdrant Vector Database', powered_by: null, display_order: 100, description: 'Vector database for storing and searching embeddings', icon: 'IconRobot', container_image: 'qdrant/qdrant:v1.16', source_repo: 'https://github.com/qdrant/qdrant', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/qdrant:/qdrant/storage`], PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] }, }, ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} }, // Disable anonymous telemetry — NOMAD is offline-first Env: ['QDRANT__TELEMETRY_DISABLED=true'], }), ui_location: '6333', installed: false, installation_status: 'idle', is_dependency_service: true, is_custom: false, category: null, depends_on: null, }, { service_name: SERVICE_NAMES.OLLAMA, friendly_name: 'AI Assistant', powered_by: 'Ollama', display_order: 3, description: 'Local AI chat that runs entirely on your hardware - no internet required', icon: 'IconWand', container_image: 'ollama/ollama:0.18.1', source_repo: 'https://github.com/ollama/ollama', container_command: 'serve', container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/ollama:/root/.ollama`], PortBindings: { '11434/tcp': [{ HostPort: '11434' }] }, }, ExposedPorts: { '11434/tcp': {} }, }), ui_location: '/chat', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'ai', depends_on: SERVICE_NAMES.QDRANT, }, { service_name: SERVICE_NAMES.CYBERCHEF, friendly_name: 'Data Tools', powered_by: 'CyberChef', display_order: 11, description: 'Swiss Army knife for data encoding, encryption, and analysis', icon: 'IconChefHat', container_image: 'ghcr.io/gchq/cyberchef:10.22.1', source_repo: 'https://github.com/gchq/CyberChef', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '80/tcp': [{ HostPort: '8100' }] }, }, ExposedPorts: { '80/tcp': {} }, }), ui_location: '8100', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'utility', depends_on: null, }, { service_name: SERVICE_NAMES.FLATNOTES, friendly_name: 'Notes', powered_by: 'FlatNotes', display_order: 10, description: 'Simple note-taking app with local storage', icon: 'IconNotes', container_image: 'dullage/flatnotes:v5.5.4', source_repo: 'https://github.com/dullage/flatnotes', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '8080/tcp': [{ HostPort: '8200' }] }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/flatnotes:/data`], }, ExposedPorts: { '8080/tcp': {} }, Env: ['FLATNOTES_AUTH_TYPE=none'], }), ui_location: '8200', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'productivity', depends_on: null, }, { service_name: SERVICE_NAMES.KOLIBRI, friendly_name: 'Education Platform', powered_by: 'Kolibri', display_order: 2, description: 'Interactive learning platform with video courses and exercises', icon: 'IconSchool', container_image: 'treehouses/kolibri:0.12.8', source_repo: 'https://github.com/learningequality/kolibri', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '8080/tcp': [{ HostPort: '8300' }] }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`], }, ExposedPorts: { '8080/tcp': {} }, }), ui_location: '8300', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'education', depends_on: null, }, // ── Supply Depot — curated catalog (ports 8400–8499) ───────────────────── { service_name: SERVICE_NAMES.STIRLING_PDF, friendly_name: 'Stirling PDF', powered_by: 'Stirling-Tools', display_order: 20, description: 'Locally-hosted PDF manipulation tool — merge, split, compress, convert, and more', icon: 'IconFileDescription', container_image: 'ghcr.io/stirling-tools/s-pdf:latest', source_repo: 'https://github.com/Stirling-Tools/Stirling-PDF', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '8080/tcp': [{ HostPort: '8400' }] }, Binds: [ `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/configs:/configs`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/logs:/logs`, ], }, ExposedPorts: { '8080/tcp': {} }, // Stirling v2 ignores the old v1 `DOCKER_ENABLE_SECURITY` flag and ships with // `security.enableLogin: true` in settings.yml, so it boots behind a login wall. // For a single-user offline appliance we open it straight to the tools. Users who // want a login can flip this to `true` via Manage > Edit (env overrides settings.yml). Env: ['SECURITY_ENABLELOGIN=false', 'LANGS=en_GB'], }), ui_location: '8400', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'productivity', depends_on: null, }, { service_name: SERVICE_NAMES.FILEBROWSER, friendly_name: 'File Browser', powered_by: 'FileBrowser', display_order: 21, description: 'Web-based file manager — browse, upload, download, and organize files on your device', icon: 'IconFolderOpen', container_image: 'filebrowser/filebrowser:v2', source_repo: 'https://github.com/filebrowser/filebrowser', // Browsable root is storage/filebrowser/files (persistent, so files created at the top level // survive updates), with the user-facing content folders mounted in beneath it. We deliberately // do NOT mount the sensitive/app-internal folders (vaultwarden, ollama, qdrant, logs, other // apps' config + *.db). They simply aren't present in the container, so they can't be browsed, // downloaded, or deleted — this is the guardrail. FileBrowser's own rules feature would need a // wrapper script / imported config shipped into the container, which the catalog model doesn't // support, so mount selection is how we scope visibility. To expose another content folder, // add a `${STORAGE}/:/srv/` bind here. // The DB lives in storage/filebrowser/db (mounted at /db, a SIBLING of the root, not under it) // so FileBrowser's own .filebrowser.db never shows up in the user's file listing. User: root so // it can read/write folders owned by other UIDs. container_command: '--root /srv --database /db/.filebrowser.db', container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '80/tcp': [{ HostPort: '8410' }] }, Binds: [ `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser/files:/srv`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser/db:/db`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/srv/books`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/maps:/srv/maps`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/srv/media`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kb_uploads:/srv/kb_uploads`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/zim:/srv/zim`, ], }, ExposedPorts: { '80/tcp': {} }, // Without an initial password FileBrowser generates a random one and prints it only to // the container logs, which a non-technical user can't reach. Seed a known admin/nomad // login on first run instead (only applies when the DB doesn't exist yet); the docs tell // users to change it. FB_NOAUTH / --noauth don't work on this image (v2.63.x), so a login // stays, which is the safer default anyway for a read/write/delete file manager. // NOTE: FB_PASSWORD must be a bcrypt hash, not plaintext. The value below is the hash of // "nomad" (generated via `filebrowser hash nomad`). Login is admin / nomad. Env: [ 'FB_USERNAME=admin', 'FB_PASSWORD=$2a$10$Dvu3XTiLxvPTzvdOKu6y6.AmadN6Zt0ddLwK.8MQ.RCIQWunWBQXa', ], User: 'root' }), ui_location: '8410', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'utility', depends_on: null, }, { service_name: SERVICE_NAMES.CALIBREWEB, friendly_name: 'Calibre Web', powered_by: 'Calibre-Web', display_order: 22, description: 'Web-based e-book reader and library manager for your Calibre collection', icon: 'IconBook', container_image: 'lscr.io/linuxserver/calibre-web:latest', source_repo: 'https://github.com/janeczku/calibre-web', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '8083/tcp': [{ HostPort: '8420' }] }, Binds: [ `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/calibreweb/config:/config`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/books`, ], }, ExposedPorts: { '8083/tcp': {} }, Env: ['PUID=1000', 'PGID=1000'], }), ui_location: '8420', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'media', depends_on: null, metadata: JSON.stringify({ minMemoryMB: 512, minDiskMB: 5120 }), }, { service_name: SERVICE_NAMES.IT_TOOLS, friendly_name: 'IT Tools', powered_by: 'IT-Tools', display_order: 23, description: 'Collection of handy utilities for developers — UUID, hash, encoding, formatters, and more', icon: 'IconTool', container_image: 'ghcr.io/corentinth/it-tools:latest', source_repo: 'https://github.com/CorentinTh/it-tools', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '80/tcp': [{ HostPort: '8430' }] }, }, ExposedPorts: { '80/tcp': {} }, }), ui_location: '8430', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'utility', depends_on: null, }, { service_name: SERVICE_NAMES.EXCALIDRAW, friendly_name: 'Excalidraw', powered_by: 'Excalidraw', display_order: 24, description: 'Virtual whiteboard for sketching hand-drawn-style diagrams — works fully offline', icon: 'IconPencil', container_image: 'excalidraw/excalidraw:latest', source_repo: 'https://github.com/excalidraw/excalidraw', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '80/tcp': [{ HostPort: '8440' }] }, }, ExposedPorts: { '80/tcp': {} }, }), ui_location: '8440', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'productivity', depends_on: null, }, { service_name: SERVICE_NAMES.MESHTASTIC_WEB, friendly_name: 'Meshtastic Web', powered_by: 'Meshtastic', display_order: 30, description: 'Browser-based client for managing Meshtastic mesh radio devices', icon: 'IconWifi', container_image: 'ghcr.io/meshtastic/web:latest', source_repo: 'https://github.com/meshtastic/web', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, // meshtastic/web serves on 8080 inside the container, not 80. PortBindings: { '8080/tcp': [{ HostPort: '8450' }] }, }, ExposedPorts: { '8080/tcp': {} }, }), ui_location: '8450', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'networking', depends_on: null, }, { service_name: SERVICE_NAMES.MESHTASTICD, friendly_name: 'Meshtastic Daemon', powered_by: 'Meshtastic', display_order: 31, description: 'Software-defined Meshtastic node with REST API — connect devices and build mesh networks', icon: 'IconBroadcast', container_image: 'meshtastic/meshtasticd:latest', source_repo: 'https://github.com/meshtastic/meshtasticd', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '4403/tcp': [{ HostPort: '8460' }] }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshtasticd:/root/.portduino`], }, ExposedPorts: { '4403/tcp': {} }, }), ui_location: '8460', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'networking', depends_on: null, }, { service_name: SERVICE_NAMES.HOMEBOX, friendly_name: 'Homebox', powered_by: 'Homebox', display_order: 25, description: 'Home inventory and asset management — track everything you own', icon: 'IconBox', // Maintained fork. The original hay-kot/homebox was archived June 2024; // sysadminsmedia is the official continuation (drop-in: same 7745 port + /data volume, // migrates an existing DB forward, telemetry off by default). container_image: 'ghcr.io/sysadminsmedia/homebox:latest', source_repo: 'https://github.com/sysadminsmedia/homebox', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '7745/tcp': [{ HostPort: '8470' }] }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/homebox:/data`], }, ExposedPorts: { '7745/tcp': {} }, }), ui_location: '8470', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'productivity', depends_on: null, }, { service_name: SERVICE_NAMES.VAULTWARDEN, friendly_name: 'Vaultwarden', powered_by: 'Vaultwarden', display_order: 26, description: 'Lightweight Bitwarden-compatible password manager server — secure your credentials offline', icon: 'IconShieldLock', container_image: 'vaultwarden/server:latest', source_repo: 'https://github.com/dani-garcia/vaultwarden', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '80/tcp': [{ HostPort: '8480' }] }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/vaultwarden:/data`], }, ExposedPorts: { '80/tcp': {} }, // ROCKET_TLS points at the self-signed cert generated on install by // DockerService._runPreinstallActions__Vaultwarden. Vaultwarden's web vault needs a secure // context (HTTPS) or it refuses to register/unlock, so it ships HTTPS-on-by-default. Env: [ 'WEBSOCKET_ENABLED=true', 'ROCKET_TLS={certs="/data/cert.pem",key="/data/key.pem"}', ], }), // https: prefix tells getServiceLink to build an https:// Open link on this port. ui_location: 'https:8480', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'security', depends_on: null, metadata: JSON.stringify({ minMemoryMB: 256, minDiskMB: 512 }), }, { service_name: SERVICE_NAMES.JELLYFIN, friendly_name: 'Jellyfin', powered_by: 'Jellyfin', display_order: 27, description: 'Open-source media server — stream your video, music, and photo libraries', icon: 'IconMovie', container_image: 'jellyfin/jellyfin:latest', source_repo: 'https://github.com/jellyfin/jellyfin', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, PortBindings: { '8096/tcp': [{ HostPort: '8490' }] }, Binds: [ `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/config:/config`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/cache:/cache`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/media`, ], }, ExposedPorts: { '8096/tcp': {} }, }), ui_location: '8490', installed: false, installation_status: 'idle', is_dependency_service: false, is_custom: false, category: 'media', depends_on: null, metadata: JSON.stringify({ minMemoryMB: 2048, minDiskMB: 20480 }), }, ] async run() { const existingServices = await Service.query().select([ 'service_name', 'is_custom', 'is_user_modified', ]) const existingServiceMap = new Map(existingServices.map((s) => [s.service_name, s])) const newServices = ServiceSeeder.DEFAULT_SERVICES.filter( (service) => !existingServiceMap.has(service.service_name) ) if (newServices.length > 0) { await Service.createMany([...newServices]) } // Keep curated services in sync with the catalog. Custom services are user-defined and must // never be overwritten. User-modified curated services (a user edited their config) are // likewise left alone so the edit survives reboots. ui_location is synced too so a catalog // change to an app's link/scheme/port (e.g. Vaultwarden moving to https:8480, or a corrected // internal port) reaches existing non-modified installs on update, not just fresh ones. for (const service of ServiceSeeder.DEFAULT_SERVICES) { const existing = existingServiceMap.get(service.service_name) if (existing && !existing.is_custom && !existing.is_user_modified) { await Service.query().where('service_name', service.service_name).update({ container_config: service.container_config, container_command: service.container_command ?? null, metadata: (service as any).metadata ?? null, category: service.category, ui_location: service.ui_location, }) } } } }