545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
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<Service>,
|
||
| 'created_at'
|
||
| 'updated_at'
|
||
| 'id'
|
||
| 'available_update_version'
|
||
| 'update_checked_at'
|
||
| 'metadata'
|
||
| 'is_user_modified'
|
||
> & { 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}/<folder>:/srv/<folder>` 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,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|