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' | 'is_deprecated' | '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.24.0', 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.24.0', 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, }, { // "Kolibri Gen 2" — the upstream-official learningequality image replacing the ~6-year-old // community treehouses/kolibri:0.12.8. This is a distinct catalog entry (own service_name, // volume, and ports), not an in-place upgrade: the new image uses a different repo, mounts at // /kolibri instead of /root/.kolibri, and crosses 7 minor versions of Kolibri's own data // schema. Existing 0.12.8 installs are sunset via the deprecate-legacy-kolibri migration and // keep running on 8300 until uninstalled; content is re-imported into the fresh Gen 2 install. service_name: SERVICE_NAMES.KOLIBRI_GEN2, friendly_name: 'Education Platform (Gen 2)', powered_by: 'Kolibri', display_order: 2, description: 'Interactive learning platform with video courses and exercises', icon: 'IconSchool', container_image: 'learningequality/kolibri:0.19.4', source_repo: 'https://github.com/learningequality/kolibri', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, // 8080 = web UI. 8311 = zip-content server (interactive exercises / HTML5 apps), served // from a separate "alternate origin" the browser connects to DIRECTLY. KOLIBRI_ZIP_CONTENT_PORT // sets the port Kolibri both LISTENS on inside the container AND advertises in content URLs, // so the internal port, the published host port, and that env value must all be identical // (8311) — otherwise content URLs point at a host port that doesn't route to the listener // and every content page fails with ERR_CONNECTION_REFUSED. The image's default 8081 is // unused here. The image refuses to start without /kolibri mounted (KOLIBRI_HOME = /kolibri). PortBindings: { '8080/tcp': [{ HostPort: '8310' }], '8311/tcp': [{ HostPort: '8311' }] }, Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri-gen2:/kolibri`], }, ExposedPorts: { '8080/tcp': {}, '8311/tcp': {} }, Env: ['KOLIBRI_ZIP_CONTENT_PORT=8311'], }), ui_location: '8310', 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:2.13.1', 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: 'linuxserver/calibre-web:0.6.26-ls386', 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:2024.10.22-7ca5933', 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:sha-4bfc5bb', 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:2.7.1', 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.MESHCORE_WEB, friendly_name: 'MeshCore Web', powered_by: 'MeshCore', display_order: 32, description: 'Browser-based client for MeshCore mesh radio devices', icon: 'IconAntenna', // aXistem's prebuilt image of Liam Cottle's MeshCore web client (MeshCore is a sibling LoRa // mesh project to Meshtastic). container_image: 'ghcr.io/axistem-dev/meshcore-web:v1.45.0', source_repo: 'https://github.com/aXistem-dev/meshcore-web', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, // The image is stock nginx:alpine serving the Flutter build over HTTP on 80. MeshCore's // client reaches a radio via Web Bluetooth / Web Serial, which browsers only permit from a // secure (HTTPS) context — so we serve it over HTTPS. _runPreinstallActions__MeshCoreWeb // writes a self-signed cert + an SSL server config into storage/meshcore-web; we bind both // in (the config over the image's default.conf) and publish 443. The https: prefix on // ui_location builds an https:// Open link (one-time cert warning, same as Vaultwarden). PortBindings: { '443/tcp': [{ HostPort: '8500' }] }, Binds: [ `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/nginx-ssl.conf:/etc/nginx/conf.d/default.conf:ro`, `${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/certs:/certs:ro`, ], }, ExposedPorts: { '443/tcp': {} }, }), ui_location: 'https:8500', 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:0.26.2', 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:1.36.0', 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:10.11.11', 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, }) } } } }