@brewnet/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +184 -0
- package/dist/admin-server-DQVIEHV3.js +14 -0
- package/dist/admin-server-DQVIEHV3.js.map +1 -0
- package/dist/boilerplate-manager-P6QYUU7Q.js +29 -0
- package/dist/boilerplate-manager-P6QYUU7Q.js.map +1 -0
- package/dist/chunk-2VWMDHGI.js +1393 -0
- package/dist/chunk-2VWMDHGI.js.map +1 -0
- package/dist/chunk-4TJMJZMO.js +1173 -0
- package/dist/chunk-4TJMJZMO.js.map +1 -0
- package/dist/chunk-BAVGYMGA.js +114 -0
- package/dist/chunk-BAVGYMGA.js.map +1 -0
- package/dist/chunk-DH2VK3YI.js +293 -0
- package/dist/chunk-DH2VK3YI.js.map +1 -0
- package/dist/chunk-HCHY5UIQ.js +301 -0
- package/dist/chunk-HCHY5UIQ.js.map +1 -0
- package/dist/chunk-JFPHGZ6Z.js +254 -0
- package/dist/chunk-JFPHGZ6Z.js.map +1 -0
- package/dist/chunk-SIXBB6JU.js +2973 -0
- package/dist/chunk-SIXBB6JU.js.map +1 -0
- package/dist/chunk-SYV6PK3R.js +181 -0
- package/dist/chunk-SYV6PK3R.js.map +1 -0
- package/dist/chunk-ZKMWE5AH.js +444 -0
- package/dist/chunk-ZKMWE5AH.js.map +1 -0
- package/dist/cloudflare-client-TFT6VCXF.js +32 -0
- package/dist/cloudflare-client-TFT6VCXF.js.map +1 -0
- package/dist/compose-generator-O7GSIJ2S.js +19 -0
- package/dist/compose-generator-O7GSIJ2S.js.map +1 -0
- package/dist/frameworks-Z7VXDGP4.js +18 -0
- package/dist/frameworks-Z7VXDGP4.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +7897 -0
- package/dist/index.js.map +1 -0
- package/dist/services/admin-daemon.d.ts +2 -0
- package/dist/services/admin-daemon.js +33 -0
- package/dist/services/admin-daemon.js.map +1 -0
- package/dist/stacks-M4FBTVO5.js +16 -0
- package/dist/stacks-M4FBTVO5.js.map +1 -0
- package/dist/state-2SI3P4JG.js +27 -0
- package/dist/state-2SI3P4JG.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/services/compose-generator.ts","../src/config/services.ts"],"sourcesContent":["/**\n * Brewnet CLI — Docker Compose Generator (T066)\n *\n * Pure-function module that transforms a WizardState into a\n * docker-compose.yml configuration object (ComposeConfig) and\n * serializes it to YAML using js-yaml.\n *\n * @module services/compose-generator\n */\n\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport yaml from 'js-yaml';\nimport type { WizardState } from '@brewnet/shared';\nimport { DOCKER_LOG_MAX_SIZE, DOCKER_LOG_MAX_FILES } from '@brewnet/shared';\nimport { SERVICE_REGISTRY } from '../config/services.js';\nimport type { ServiceDefinition } from '../config/services.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ComposeHealthcheck {\n test: string[];\n interval: string;\n timeout: string;\n retries: number;\n}\n\nexport interface ComposeLogging {\n driver: string;\n options: Record<string, string>;\n}\n\nexport interface ComposeService {\n image?: string;\n build?: string;\n container_name: string;\n restart: 'unless-stopped';\n security_opt: string[];\n networks: string[];\n ports?: string[];\n volumes?: string[];\n environment?: Record<string, string>;\n labels?: Record<string, string>;\n depends_on?: string[];\n healthcheck?: ComposeHealthcheck;\n command?: string | string[];\n entrypoint?: string[];\n secrets?: string[];\n logging?: ComposeLogging;\n}\n\nexport interface ComposeConfig {\n name: string;\n services: Record<string, ComposeService>;\n networks: Record<string, { external?: boolean; internal?: boolean }>;\n volumes?: Record<string, null>;\n secrets?: Record<string, { file: string }>;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst BREWNET_PREFIX = 'brewnet';\n\n// ---------------------------------------------------------------------------\n// Logging configuration\n// ---------------------------------------------------------------------------\n\nfunction getLoggingConfig(): ComposeLogging {\n return {\n driver: 'json-file',\n options: {\n 'max-size': DOCKER_LOG_MAX_SIZE,\n 'max-file': DOCKER_LOG_MAX_FILES,\n tag: '{{.Name}}',\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Volume definitions per service\n// ---------------------------------------------------------------------------\n\nfunction getServiceVolumes(serviceId: string): string[] {\n switch (serviceId) {\n case 'traefik':\n return [\n '/var/run/docker.sock:/var/run/docker.sock',\n `${BREWNET_PREFIX}_traefik_certs:/letsencrypt`,\n './logs:/logs',\n ];\n case 'gitea':\n return [`${BREWNET_PREFIX}_gitea_data:/data`];\n case 'postgresql':\n return [`${BREWNET_PREFIX}_postgres_data:/var/lib/postgresql/data`];\n case 'mysql':\n return [`${BREWNET_PREFIX}_mysql_data:/var/lib/mysql`];\n case 'nextcloud':\n return [\n `${BREWNET_PREFIX}_nextcloud_data:/var/www/html`,\n ];\n case 'minio':\n return [`${BREWNET_PREFIX}_minio_data:/data`];\n case 'jellyfin':\n return [\n `${BREWNET_PREFIX}_jellyfin_config:/config`,\n `${BREWNET_PREFIX}_jellyfin_media:/media`,\n ];\n case 'openssh-server':\n return [`${BREWNET_PREFIX}_ssh_config:/config`];\n case 'pgadmin':\n return [`${BREWNET_PREFIX}_pgadmin_data:/var/lib/pgadmin`];\n case 'filebrowser':\n return [\n `${BREWNET_PREFIX}_filebrowser_data:/srv`,\n `${BREWNET_PREFIX}_filebrowser_db:/database`,\n `${BREWNET_PREFIX}_filebrowser_config:/config`,\n ];\n case 'cloudflared':\n return [];\n default:\n return [];\n }\n}\n\n// ---------------------------------------------------------------------------\n// Healthcheck builders\n// ---------------------------------------------------------------------------\n\nfunction getHealthcheck(serviceId: string, state: WizardState): ComposeHealthcheck | undefined {\n switch (serviceId) {\n case 'postgresql':\n return {\n test: ['CMD-SHELL', `pg_isready -U ${state.servers.dbServer.dbUser || 'brewnet'}`],\n interval: '10s',\n timeout: '5s',\n retries: 5,\n };\n case 'mysql':\n return {\n test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'],\n interval: '10s',\n timeout: '5s',\n retries: 5,\n };\n case 'gitea':\n return {\n test: ['CMD-SHELL', 'curl -fsSL http://localhost:3000/api/healthz || exit 1'],\n interval: '30s',\n timeout: '10s',\n retries: 3,\n };\n case 'traefik':\n return {\n test: ['CMD-SHELL', 'wget --spider -q http://localhost:8080/api/overview || exit 1'],\n interval: '30s',\n timeout: '5s',\n retries: 3,\n };\n case 'nextcloud':\n return {\n test: ['CMD-SHELL', 'curl -fsSL http://localhost/status.php || exit 1'],\n interval: '30s',\n timeout: '10s',\n retries: 5,\n };\n case 'jellyfin':\n return {\n test: ['CMD-SHELL', 'curl -fsSL http://localhost:8096/health || exit 1'],\n interval: '30s',\n timeout: '10s',\n retries: 3,\n };\n default:\n return undefined;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Environment variable builders\n// ---------------------------------------------------------------------------\n\nfunction getPostgresqlEnv(state: WizardState): Record<string, string> {\n const db = state.servers.dbServer;\n return {\n POSTGRES_USER: db.dbUser || 'brewnet',\n POSTGRES_PASSWORD: db.dbPassword || '${DB_PASSWORD}',\n POSTGRES_DB: db.dbName || 'brewnet_db',\n };\n}\n\nfunction getMysqlEnv(state: WizardState): Record<string, string> {\n const db = state.servers.dbServer;\n return {\n MYSQL_ROOT_PASSWORD: db.dbPassword || '${DB_PASSWORD}',\n MYSQL_DATABASE: db.dbName || 'brewnet_db',\n MYSQL_USER: db.dbUser || 'brewnet',\n MYSQL_PASSWORD: db.dbPassword || '${DB_PASSWORD}',\n };\n}\n\nfunction getGiteaEnv(state: WizardState): Record<string, string> {\n const env: Record<string, string> = {\n USER_UID: '1000',\n USER_GID: '1000',\n // Lock the web installer — prevents the setup wizard from running on first access.\n // Without this, visiting the Gitea URL triggers the installer which writes app.ini\n // with whatever credentials the user enters (often wrong defaults).\n // Once app.ini exists, Gitea ignores ALL env vars → DB password mismatch on restart.\n // With INSTALL_LOCK=true, Gitea reads DB credentials exclusively from GITEA__database__* env vars.\n 'GITEA__security__INSTALL_LOCK': 'true',\n };\n\n // ROOT_URL and server domain depend on routing mode:\n //\n // Quick Tunnel: ONE random URL → no subdomain → path-prefix routing required.\n // ROOT_URL = http://localhost/git/ (sub-path so Gitea generates /git/assets/... links)\n // Traefik strips /git → Gitea receives clean /assets/... paths ✓\n // Direct port access broken (CSS 404) — intentional; use localhost/git/ via Traefik.\n //\n // Named Tunnel / No Tunnel: own domain → subdomain routing available.\n // ROOT_URL = https://git.${domain}/ (no sub-path → direct port access works like FileBrowser)\n // DOMAIN / SSH_DOMAIN = git.${domain} so SSH clone URLs and notification emails are correct.\n // Traefik routes Host(`git.${domain}`) → gitea:3000 (no strip-prefix, no sub-path).\n const giteaTunnelMode = state.domain.cloudflare.tunnelMode;\n const giteaDomain = state.domain.name;\n if (giteaTunnelMode === 'quick') {\n env['GITEA__server__ROOT_URL'] = 'http://localhost/git/';\n } else if (giteaDomain) {\n // Named Tunnel or local — subdomain routing, direct port access enabled (like FileBrowser/Jellyfin).\n env['GITEA__server__ROOT_URL'] = `https://git.${giteaDomain}/`;\n env['GITEA__server__DOMAIN'] = `git.${giteaDomain}`;\n env['GITEA__server__SSH_DOMAIN'] = `git.${giteaDomain}`;\n }\n\n if (state.servers.dbServer.enabled && state.servers.dbServer.primary) {\n const dbType = state.servers.dbServer.primary === 'postgresql' ? 'postgres' : 'mysql';\n const dbHost = state.servers.dbServer.primary === 'postgresql' ? 'postgresql' : 'mysql';\n const dbPort = state.servers.dbServer.primary === 'postgresql' ? '5432' : '3306';\n\n env['GITEA__database__DB_TYPE'] = dbType;\n env['GITEA__database__HOST'] = `${dbHost}:${dbPort}`;\n // Gitea uses a dedicated 'gitea_db' to avoid schema conflicts with Nextcloud (oc_* tables)\n // which also shares the same PostgreSQL instance under brewnet_db.\n env['GITEA__database__NAME'] = 'gitea_db';\n env['GITEA__database__USER'] = state.servers.dbServer.dbUser || 'brewnet';\n env['GITEA__database__PASSWD'] = state.servers.dbServer.dbPassword || '${DB_PASSWORD}';\n }\n\n return env;\n}\n\nfunction getNextcloudEnv(state: WizardState): Record<string, string> {\n const env: Record<string, string> = {\n NEXTCLOUD_ADMIN_USER: state.admin.username || 'admin',\n NEXTCLOUD_ADMIN_PASSWORD: state.admin.password || '${ADMIN_PASSWORD}',\n NEXTCLOUD_TRUSTED_DOMAINS: state.domain.name,\n };\n\n // Nextcloud reverse-proxy overwrite settings depend on routing mode:\n //\n // Quick Tunnel: path-prefix routing required (single random URL, no subdomain).\n // OVERWRITEWEBROOT=/cloud → NC generates /cloud/... links; Traefik strips /cloud ✓\n // Direct port access broken — intentional; use localhost/cloud/ via Traefik.\n //\n // Named Tunnel / No Tunnel: subdomain routing available (cloud.${domain}).\n // No OVERWRITEWEBROOT needed — NC serves from root / → direct port access works.\n // OVERWRITEHOST + OVERWRITEPROTOCOL tell NC what the external URL looks like so\n // share links, CalDAV/CardDAV, and email notifications use the correct domain.\n // Ref: https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/reverse_proxy_configuration.html\n const ncTunnelMode = state.domain.cloudflare.tunnelMode;\n const ncDomain = state.domain.name;\n if (ncTunnelMode === 'quick') {\n env['OVERWRITEWEBROOT'] = '/cloud';\n env['NEXTCLOUD_TRUSTED_PROXIES'] = 'traefik';\n // Include localhost variants for direct-port access; tunnel domain is\n // added post-install via `occ` once the Quick Tunnel URL is known.\n const portMap = state.portRemapping ?? {};\n const ncPort = portMap[8443] ?? 8443;\n env['NEXTCLOUD_TRUSTED_DOMAINS'] = `${ncDomain} localhost localhost:${ncPort} *.trycloudflare.com`;\n } else if (ncDomain) {\n // Named Tunnel or local — subdomain routing, direct port access enabled.\n env['OVERWRITEHOST'] = `cloud.${ncDomain}`;\n env['OVERWRITEPROTOCOL'] = 'https';\n env['NEXTCLOUD_TRUSTED_PROXIES'] = 'traefik';\n env['NEXTCLOUD_TRUSTED_DOMAINS'] = `cloud.${ncDomain} localhost`;\n }\n\n if (state.servers.dbServer.enabled && state.servers.dbServer.primary) {\n if (state.servers.dbServer.primary === 'postgresql') {\n env['POSTGRES_HOST'] = 'postgresql';\n env['POSTGRES_DB'] = state.servers.dbServer.dbName || 'brewnet_db';\n env['POSTGRES_USER'] = state.servers.dbServer.dbUser || 'brewnet';\n env['POSTGRES_PASSWORD'] = state.servers.dbServer.dbPassword || '${DB_PASSWORD}';\n } else if (state.servers.dbServer.primary === 'mysql') {\n env['MYSQL_HOST'] = 'mysql';\n env['MYSQL_DATABASE'] = state.servers.dbServer.dbName || 'brewnet_db';\n env['MYSQL_USER'] = state.servers.dbServer.dbUser || 'brewnet';\n env['MYSQL_PASSWORD'] = state.servers.dbServer.dbPassword || '${DB_PASSWORD}';\n }\n }\n\n return env;\n}\n\nfunction getMinioEnv(state: WizardState): Record<string, string> {\n return {\n MINIO_ROOT_USER: state.admin.username || 'admin',\n MINIO_ROOT_PASSWORD: state.admin.password || '${ADMIN_PASSWORD}',\n };\n}\n\nfunction getSshEnv(state: WizardState): Record<string, string> {\n return {\n PUID: '1000',\n PGID: '1000',\n TZ: 'UTC',\n PASSWORD_ACCESS: state.servers.sshServer.passwordAuth ? 'true' : 'false',\n USER_NAME: state.admin.username || 'admin',\n };\n}\n\nfunction getPgadminEnv(state: WizardState): Record<string, string> {\n const env: Record<string, string> = {\n PGADMIN_DEFAULT_EMAIL: state.servers.dbServer.pgadminEmail || `${state.admin.username || 'admin'}@brewnet.dev`,\n PGADMIN_DEFAULT_PASSWORD: state.admin.password || '${ADMIN_PASSWORD}',\n };\n // In Quick Tunnel mode pgadmin is served under /pgadmin path prefix,\n // so SCRIPT_NAME must be set so pgadmin generates correct relative URLs.\n if (state.domain.cloudflare.tunnelMode === 'quick') {\n env['SCRIPT_NAME'] = '/pgadmin';\n }\n return env;\n}\n\n// FB_USERNAME / FB_PASSWORD are only applied on first boot when no DB exists.\n// FB_BASEURL must match the path prefix Traefik uses so login redirects work.\n// Ref: https://filebrowser.org/configuration\nfunction getFilebrowserEnv(state: WizardState): Record<string, string> {\n const env: Record<string, string> = {\n FB_USERNAME: state.admin.username || 'admin',\n FB_PASSWORD: state.admin.password || '${ADMIN_PASSWORD}',\n };\n if (state.domain.cloudflare.tunnelMode === 'quick') {\n env['FB_BASEURL'] = '/files';\n }\n return env;\n}\n\nfunction getCloudflaredEnv(state: WizardState): Record<string, string> | undefined {\n if (state.domain.cloudflare.tunnelMode === 'quick') {\n // Quick Tunnel needs no TUNNEL_TOKEN env var\n return undefined;\n }\n return {\n TUNNEL_TOKEN: state.domain.cloudflare.tunnelToken || '${TUNNEL_TOKEN}',\n };\n}\n\n// ---------------------------------------------------------------------------\n// Label builders (Traefik routing)\n// ---------------------------------------------------------------------------\n\nfunction resolveTraefikLabels(\n def: ServiceDefinition,\n domain: string,\n): Record<string, string> {\n if (!def.traefikLabels) return {};\n\n const resolved: Record<string, string> = {};\n for (const [key, value] of Object.entries(def.traefikLabels)) {\n resolved[key] = value.replace(/\\{\\{DOMAIN\\}\\}/g, domain);\n }\n return resolved;\n}\n\n/**\n * Traefik path-prefix routing labels for Quick Tunnel mode.\n *\n * Quick Tunnel exposes all services under the same *.trycloudflare.com URL\n * using path prefixes: /files → filebrowser, /git → gitea, /cloud → nextcloud, etc.\n *\n * @param serviceId The compose service identifier\n * @param path The URL path prefix (e.g. \"/files\")\n * @param port The container HTTP port (must be explicit to avoid Traefik picking the wrong port)\n * @param noStrip When true, the path prefix is NOT stripped before forwarding.\n * WSGI apps (pgadmin) rely on SCRIPT_NAME and need the full path intact.\n */\nfunction buildQuickTunnelPathLabels(\n serviceId: string,\n path: string,\n port: number,\n noStrip = false,\n): Record<string, string> {\n const name = `quicktunnel-${serviceId}`;\n const labels: Record<string, string> = {\n 'traefik.enable': 'true',\n [`traefik.http.routers.${name}.rule`]: `PathPrefix(\\`${path}\\`)`,\n [`traefik.http.routers.${name}.entrypoints`]: 'web',\n [`traefik.http.services.${name}.loadbalancer.server.port`]: String(port),\n };\n if (!noStrip) {\n labels[`traefik.http.middlewares.${name}-strip.stripprefix.prefixes`] = path;\n labels[`traefik.http.routers.${name}.middlewares`] = `${name}-strip`;\n }\n return labels;\n}\n\ninterface QuickTunnelEntry {\n path: string;\n port: number;\n /** When true, prefix is preserved in the upstream request (needed by WSGI apps using SCRIPT_NAME). */\n noStrip?: boolean;\n /**\n * Extra root-level paths this service must also handle.\n * Used for FileBrowser: its Vite build always emits /static/... asset paths regardless of FB_BASEURL.\n * Without routing /static → FileBrowser, browsers hit the landing-page catch-all and the app fails.\n */\n extraPaths?: string[];\n}\n\nfunction buildQuickTunnelExtraPathLabels(\n serviceId: string,\n extraPath: string,\n port: number,\n): Record<string, string> {\n const slug = extraPath.replace(/\\//g, '').replace(/[^a-z0-9]/gi, '');\n const routerName = `quicktunnel-${serviceId}-${slug}`;\n const serviceName = `quicktunnel-${serviceId}-${slug}`;\n return {\n // Explicit service link required when the container has multiple service definitions\n [`traefik.http.routers.quicktunnel-${serviceId}.service`]: `quicktunnel-${serviceId}`,\n [`traefik.http.routers.${routerName}.rule`]: `PathPrefix(\\`${extraPath}\\`)`,\n [`traefik.http.routers.${routerName}.entrypoints`]: 'web',\n // Priority 10 > landing-page explicit priority 1 so this route takes precedence\n [`traefik.http.routers.${routerName}.priority`]: '10',\n [`traefik.http.routers.${routerName}.service`]: serviceName,\n [`traefik.http.services.${serviceName}.loadbalancer.server.port`]: String(port),\n };\n}\n\n// Path-prefix routing map for Quick Tunnel mode\nconst QUICK_TUNNEL_PATH_MAP: Record<string, QuickTunnelEntry> = {\n // Gitea: Traefik strips /git before forwarding to Gitea (strip-prefix).\n // ROOT_URL must include /git/ sub-path so Gitea generates /git/assets/... links.\n // ROOT_URL host must be Traefik's port (80) not Gitea's port (3000):\n // - Gitea generates root-relative links: /git/assets/...\n // - Browser resolves them against current origin (both local :80 and external tunnel)\n // - Traefik receives /git/assets/... → strips /git → sends /assets/... to Gitea ✓\n gitea: { path: '/git', port: 3000 },\n // FileBrowser: Vite build emits /static/... asset paths regardless of FB_BASEURL.\n // A companion route for /static is required so browsers can load CSS/JS.\n // See: buildQuickTunnelExtraPathLabels for the /static → filebrowser routing.\n filebrowser: { path: '/files', port: 80, extraPaths: ['/static'] },\n 'uptime-kuma': { path: '/status', port: 3001 },\n grafana: { path: '/grafana', port: 3000 },\n // pgadmin: WSGI app — SCRIPT_NAME handles path; no strip needed\n pgadmin: { path: '/pgadmin', port: 80, noStrip: true },\n // Nextcloud: OVERWRITEWEBROOT env makes NC generate prefixed URLs; strip prefix so NC gets clean paths\n // Ref: https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/reverse_proxy_configuration.html\n nextcloud: { path: '/cloud', port: 80 },\n // Jellyfin: Base URL setting handles path natively; no strip needed\n // Ref: https://jellyfin.org/docs/general/post-install/networking — \"Base URL\"\n jellyfin: { path: '/jellyfin', port: 8096, noStrip: true },\n // MinIO Console: pre-built React SPA without sub-path awareness; use noStrip so the app\n // receives the full path and Traefik does not alter the prefix.\n minio: { path: '/minio', port: 9001, noStrip: true },\n};\n\n// ---------------------------------------------------------------------------\n// Port mapping builders\n// ---------------------------------------------------------------------------\n\nfunction getServicePorts(serviceId: string, state: WizardState): string[] {\n // Apply portRemapping: maps default host port → user-selected alternative host port\n const portMap = state.portRemapping ?? {};\n const remap = (hostPort: number): number => portMap[hostPort] ?? hostPort;\n\n switch (serviceId) {\n case 'traefik': {\n const httpHost = remap(80);\n const isQuickTunnel = state.domain.cloudflare.tunnelMode === 'quick';\n // No port 8080 — Dashboard uses label-based routing (api.insecure=false)\n // Quick Tunnel: Cloudflare handles HTTPS externally; exposing 443 locally\n // causes browsers to auto-upgrade HTTP→HTTPS, hit Traefik's default\n // self-signed cert, and get 404 (no websecure routers defined).\n if (isQuickTunnel) {\n return [`${httpHost}:80`];\n }\n const httpsHost = remap(443);\n return [`${httpHost}:80`, `${httpsHost}:443`];\n }\n case 'nginx':\n return [`${remap(80)}:80`, `${remap(443)}:443`];\n case 'caddy':\n return [`${remap(80)}:80`, `${remap(443)}:443`];\n case 'gitea': {\n // Quick Tunnel: ROOT_URL=http://localhost/git/ sets AppSubUrl=/git/.\n // Gitea generates /git/assets/... links but its HTTP router handles routes WITHOUT the prefix.\n // Traefik strips /git/ before forwarding → Gitea receives bare /assets/... paths ✓\n // Direct port access (localhost:3000) returns HTML with /git/assets/... links but\n // Gitea cannot serve /git/assets/... locally → 404 on assets → CSS broken.\n // Therefore in Quick Tunnel mode: HTTP port is NOT host-exposed; use localhost/git/ (Traefik).\n // Named Tunnel / no tunnel: ROOT_URL has no sub-path → direct port access works fine.\n const ports = [`${remap(state.servers.gitServer.sshPort)}:22`];\n if (state.domain.cloudflare.tunnelMode !== 'quick') {\n ports.unshift(`${remap(state.servers.gitServer.port)}:3000`);\n }\n return ports;\n }\n case 'openssh-server':\n return [`${remap(state.servers.sshServer.port)}:2222`];\n case 'jellyfin':\n return [`${remap(8096)}:8096`];\n case 'nextcloud': {\n // Quick Tunnel: OVERWRITEWEBROOT=/cloud makes direct-port access broken\n // (NC generates /cloud/... links but Apache root has no /cloud/ subdir).\n // Do NOT expose host port — all access must go through Traefik at localhost/cloud.\n // Same pattern as Gitea (L501-504): hide HTTP port when using path-prefix routing.\n // Named Tunnel / no tunnel: OVERWRITEWEBROOT not set → direct port access works.\n if (state.domain.cloudflare.tunnelMode === 'quick') {\n return [];\n }\n return [`${remap(8443)}:80`];\n }\n case 'minio':\n return [`${remap(9000)}:9000`, `${remap(9001)}:9001`];\n case 'filebrowser':\n return [`${remap(8085)}:80`];\n case 'pgadmin':\n return [`${remap(5050)}:80`];\n case 'cloudflared':\n return [];\n // DB ports are NOT exposed externally\n case 'postgresql':\n case 'mysql':\n return [];\n default:\n return [];\n }\n}\n\n// ---------------------------------------------------------------------------\n// depends_on builder\n// ---------------------------------------------------------------------------\n\nfunction getDependsOn(serviceId: string, state: WizardState): string[] {\n const deps: string[] = [];\n\n const dbEnabled = state.servers.dbServer.enabled && state.servers.dbServer.primary;\n const primaryId = state.servers.dbServer.primary; // 'postgresql' | 'mysql' | ...\n\n switch (serviceId) {\n case 'gitea':\n if (dbEnabled) deps.push(primaryId);\n break;\n case 'nextcloud':\n if (dbEnabled) deps.push(primaryId);\n break;\n case 'pgadmin':\n deps.push('postgresql');\n break;\n default:\n break;\n }\n\n return deps;\n}\n\n// ---------------------------------------------------------------------------\n// Service environment dispatcher\n// ---------------------------------------------------------------------------\n\nfunction getServiceEnvironment(\n serviceId: string,\n state: WizardState,\n): Record<string, string> | undefined {\n switch (serviceId) {\n case 'postgresql':\n return getPostgresqlEnv(state);\n case 'mysql':\n return getMysqlEnv(state);\n case 'gitea':\n return getGiteaEnv(state);\n case 'nextcloud':\n return getNextcloudEnv(state);\n case 'minio':\n return getMinioEnv(state);\n case 'openssh-server':\n return getSshEnv(state);\n case 'pgadmin':\n return getPgadminEnv(state);\n case 'filebrowser':\n return getFilebrowserEnv(state);\n case 'cloudflared':\n return getCloudflaredEnv(state);\n default:\n return undefined;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Build a single ComposeService from a ServiceDefinition\n// ---------------------------------------------------------------------------\n\nfunction buildComposeService(\n def: ServiceDefinition,\n state: WizardState,\n): ComposeService {\n const domain = state.domain.name;\n const webService = state.servers.webServer.service;\n\n // Base service scaffold (constitution requirements)\n const svc: ComposeService = {\n image: def.image,\n container_name: `${BREWNET_PREFIX}-${def.id}`,\n restart: 'unless-stopped',\n security_opt: ['no-new-privileges:true'],\n networks: [...def.networks],\n };\n\n // Ports\n const ports = getServicePorts(def.id, state);\n if (ports.length > 0) {\n svc.ports = ports;\n }\n\n // Volumes\n const volumes = getServiceVolumes(def.id);\n if (volumes.length > 0) {\n svc.volumes = volumes;\n }\n\n // Logging — json-file driver with rotation for all services\n svc.logging = getLoggingConfig();\n\n // Environment\n const environment = getServiceEnvironment(def.id, state);\n if (environment) {\n svc.environment = environment;\n }\n\n // Traefik labels — Named Tunnel / local mode (subdomain-based routing)\n if (\n webService === 'traefik' &&\n def.traefikLabels &&\n state.domain.cloudflare.tunnelMode !== 'quick'\n ) {\n svc.labels = resolveTraefikLabels(def, domain);\n }\n\n // Traefik labels — Quick Tunnel mode (path-prefix routing)\n if (webService === 'traefik' && state.domain.cloudflare.tunnelMode === 'quick') {\n const entry = QUICK_TUNNEL_PATH_MAP[def.id];\n if (entry) {\n svc.labels = buildQuickTunnelPathLabels(def.id, entry.path, entry.port, entry.noStrip);\n for (const extraPath of (entry.extraPaths ?? [])) {\n Object.assign(svc.labels, buildQuickTunnelExtraPathLabels(def.id, extraPath, entry.port));\n }\n }\n }\n\n // depends_on\n const deps = getDependsOn(def.id, state);\n if (deps.length > 0) {\n svc.depends_on = deps;\n }\n\n // Healthcheck\n const hc = getHealthcheck(def.id, state);\n if (hc) {\n svc.healthcheck = hc;\n }\n\n // Traefik command — define entrypoints and enable Docker provider\n if (def.id === 'traefik') {\n const isQuickTunnel = state.domain.cloudflare.tunnelMode === 'quick';\n const cmds: string[] = [\n '--providers.docker=true',\n '--providers.docker.exposedbydefault=false',\n '--providers.docker.network=brewnet',\n '--entrypoints.web.address=:80',\n ];\n // Websecure entrypoint (HTTPS) is only needed when a real SSL cert is in use.\n // Quick Tunnel: Cloudflare terminates HTTPS; exposing port 443 locally causes\n // browsers to auto-upgrade HTTP→HTTPS and hit a 404 (no websecure routers).\n if (!isQuickTunnel) {\n cmds.push('--entrypoints.websecure.address=:443');\n }\n if (isQuickTunnel) {\n // Expose Traefik API/dashboard without auth only in Quick Tunnel mode\n // (local-only access). Named Tunnel and SSL modes expose the host externally,\n // so the dashboard must not be accessible without authentication.\n cmds.push('--api.insecure=true');\n // Preserve X-Forwarded-Proto from cloudflared so services behind Traefik\n // (e.g. Nextcloud) can detect the original protocol without hardcoding\n // OVERWRITEPROTOCOL. cloudflared sets X-Forwarded-Proto: https for\n // tunnel traffic; local access gets http from the entrypoint.\n cmds.push('--entrypoints.web.forwardedHeaders.insecure=true');\n }\n // Access log — JSON format, buffered writes, minimal header retention\n cmds.push(\n '--accesslog=true',\n '--accesslog.filepath=/logs/access.log',\n '--accesslog.format=json',\n '--accesslog.bufferingsize=100',\n '--accesslog.fields.headers.defaultmode=drop',\n '--accesslog.fields.headers.names.User-Agent=keep',\n '--accesslog.fields.headers.names.X-Forwarded-For=keep',\n );\n svc.command = cmds;\n\n // Dashboard labels — BasicAuth-protected access to Traefik Dashboard/API\n // via /dashboard and /api paths. Uses admin credentials from wizard state.\n svc.labels = {\n 'traefik.enable': 'true',\n 'traefik.http.routers.brewnet-dashboard.rule':\n \"PathPrefix(`/dashboard`) || PathPrefix(`/api`)\",\n 'traefik.http.routers.brewnet-dashboard.entrypoints': 'web',\n 'traefik.http.routers.brewnet-dashboard.service': 'api@internal',\n // Redirect /dashboard (no trailing slash) → /dashboard/ before auth.\n // api@internal returns 404 for paths without trailing slash.\n 'traefik.http.routers.brewnet-dashboard.middlewares': 'dashboard-slash-redirect@docker,dashboard-auth@docker',\n 'traefik.http.middlewares.dashboard-slash-redirect.redirectregex.regex': '^(https?://[^/]+)/dashboard$',\n 'traefik.http.middlewares.dashboard-slash-redirect.redirectregex.replacement': '$${1}/dashboard/',\n 'traefik.http.middlewares.dashboard-slash-redirect.redirectregex.permanent': 'false',\n 'traefik.http.middlewares.dashboard-auth.basicauth.users':\n '${TRAEFIK_DASHBOARD_AUTH}',\n };\n }\n\n // Cloudflared command — branch on tunnelMode\n if (def.id === 'cloudflared') {\n if (state.domain.cloudflare.tunnelMode === 'quick') {\n // Quick Tunnel: no account needed, URL is auto-assigned by Cloudflare\n svc.command = ['tunnel', '--no-autoupdate', '--url', 'http://traefik:80'];\n } else {\n // Named Tunnel: requires TUNNEL_TOKEN env var\n svc.command = ['tunnel', '--no-autoupdate', 'run'];\n }\n }\n\n // Jellyfin Base URL — Traefik always routes Jellyfin via /jellyfin with noStrip,\n // so Jellyfin must always know its BaseUrl is /jellyfin regardless of tunnel mode.\n // Jellyfin reads BaseUrl from /config/config/network.xml; we inject it via entrypoint.\n // Ref: https://jellyfin.org/docs/general/post-install/networking — \"Base URL\"\n if (def.id === 'jellyfin') {\n svc.entrypoint = ['/bin/sh', '-c',\n 'mkdir -p /config/config && ' +\n 'if [ ! -f /config/config/network.xml ]; then ' +\n 'echo \\'<?xml version=\"1.0\" encoding=\"utf-8\"?><NetworkConfiguration xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"><BaseUrl>/jellyfin</BaseUrl></NetworkConfiguration>\\' > /config/config/network.xml; ' +\n 'fi && ' +\n 'exec /jellyfin/jellyfin',\n ];\n }\n\n // MinIO command\n if (def.id === 'minio') {\n svc.command = ['server', '/data', '--console-address', ':9001'];\n }\n\n return svc;\n}\n\n// ---------------------------------------------------------------------------\n// Docker Secrets Migration\n// ---------------------------------------------------------------------------\n\n/**\n * Apply file-based Docker secrets to a compose service.\n *\n * For each service, this function:\n * 1. Removes secret env vars (passwords, tokens)\n * 2. Adds _FILE env vars pointing to /run/secrets/<name>\n * 3. Sets svc.secrets array\n * 4. For services without _FILE support, applies workarounds\n * (redis: command override, traefik: usersfile label)\n *\n * Services that stay in .env (no _FILE support):\n * - minio (MINIO_ROOT_PASSWORD)\n * - cloudflared (TUNNEL_TOKEN)\n */\nfunction applySecretsMigration(\n serviceId: string,\n svc: ComposeService,\n _state: WizardState,\n): void {\n const env = svc.environment ?? {};\n const secrets: string[] = [];\n\n switch (serviceId) {\n // --- PostgreSQL: POSTGRES_PASSWORD → POSTGRES_PASSWORD_FILE ---\n case 'postgresql': {\n delete env['POSTGRES_PASSWORD'];\n env['POSTGRES_PASSWORD_FILE'] = '/run/secrets/db_password';\n secrets.push('db_password');\n break;\n }\n\n // --- MySQL: MYSQL_ROOT_PASSWORD + MYSQL_PASSWORD → _FILE variants ---\n case 'mysql': {\n delete env['MYSQL_ROOT_PASSWORD'];\n delete env['MYSQL_PASSWORD'];\n env['MYSQL_ROOT_PASSWORD_FILE'] = '/run/secrets/db_password';\n env['MYSQL_PASSWORD_FILE'] = '/run/secrets/db_password';\n secrets.push('db_password');\n break;\n }\n\n // --- Gitea: database PASSWD, security SECRET_KEY → __FILE variants ---\n case 'gitea': {\n if (env['GITEA__database__PASSWD']) {\n delete env['GITEA__database__PASSWD'];\n env['GITEA__database__PASSWD__FILE'] = '/run/secrets/db_password';\n secrets.push('db_password');\n }\n // SECRET_KEY is always set via credential-manager\n // Gitea uses double-underscore __FILE convention\n delete env['GITEA__security__SECRET_KEY'];\n // Gitea does not natively support __FILE for SECRET_KEY;\n // we write the value directly to the secret and use entrypoint to inject it.\n // For now, leave it as env var pointing to secret file for manual handling.\n secrets.push('gitea_secret_key');\n break;\n }\n\n // --- Nextcloud: NEXTCLOUD_ADMIN_PASSWORD, DB passwords → _FILE ---\n case 'nextcloud': {\n delete env['NEXTCLOUD_ADMIN_PASSWORD'];\n env['NEXTCLOUD_ADMIN_PASSWORD_FILE'] = '/run/secrets/admin_password';\n secrets.push('admin_password');\n\n if (env['POSTGRES_PASSWORD']) {\n delete env['POSTGRES_PASSWORD'];\n env['POSTGRES_PASSWORD_FILE'] = '/run/secrets/db_password';\n secrets.push('db_password');\n }\n if (env['MYSQL_PASSWORD']) {\n delete env['MYSQL_PASSWORD'];\n env['MYSQL_PASSWORD_FILE'] = '/run/secrets/db_password';\n secrets.push('db_password');\n }\n break;\n }\n\n // --- pgAdmin: PGADMIN_DEFAULT_PASSWORD → _FILE ---\n case 'pgadmin': {\n delete env['PGADMIN_DEFAULT_PASSWORD'];\n env['PGADMIN_DEFAULT_PASSWORD_FILE'] = '/run/secrets/admin_password';\n secrets.push('admin_password');\n break;\n }\n\n // --- Traefik: basicauth.users → basicauth.usersfile (BasicAuth bug fix) ---\n case 'traefik': {\n secrets.push('traefik_dashboard_auth');\n // Fix BasicAuth: switch from env-interpolated users label to usersfile\n if (svc.labels) {\n delete svc.labels['traefik.http.middlewares.dashboard-auth.basicauth.users'];\n svc.labels['traefik.http.middlewares.dashboard-auth.basicauth.usersfile'] =\n '/run/secrets/traefik_dashboard_auth';\n }\n break;\n }\n\n // minio, cloudflared: no changes (stay in .env)\n default:\n break;\n }\n\n // Deduplicate secrets\n const uniqueSecrets = [...new Set(secrets)];\n if (uniqueSecrets.length > 0) {\n svc.secrets = uniqueSecrets;\n }\n if (Object.keys(env).length > 0) {\n svc.environment = env;\n }\n}\n\n/**\n * Collect top-level secrets block from all services.\n * Returns a record of secret name → { file: './secrets/<name>' }.\n */\nfunction collectTopLevelSecrets(\n services: Record<string, ComposeService>,\n): Record<string, { file: string }> | undefined {\n const allSecrets = new Set<string>();\n for (const svc of Object.values(services)) {\n for (const s of svc.secrets ?? []) {\n allSecrets.add(s);\n }\n }\n if (allSecrets.size === 0) return undefined;\n\n const result: Record<string, { file: string }> = {};\n for (const name of allSecrets) {\n result[name] = { file: `./secrets/${name}` };\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Named volume collector\n// ---------------------------------------------------------------------------\n\n/**\n * Extract all named (non-bind-mount) volumes from a services map.\n * Bind mounts start with '/' or '.'; everything else is a named volume.\n */\nfunction collectNamedVolumes(\n services: Record<string, ComposeService>,\n): Record<string, null> {\n const volumes: Record<string, null> = {};\n for (const svc of Object.values(services)) {\n for (const vol of svc.volumes ?? []) {\n const hostPart = vol.split(':')[0];\n if (!hostPart.startsWith('/') && !hostPart.startsWith('.')) {\n volumes[hostPart] = null;\n }\n }\n }\n return volumes;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a ComposeConfig object from WizardState.\n *\n * The output can be serialized to YAML with `composeConfigToYaml()`.\n */\nexport function generateComposeConfig(state: WizardState): ComposeConfig {\n const services: Record<string, ComposeService> = {};\n\n // 1. Web server (required, always enabled)\n const webId = state.servers.webServer.service; // 'traefik' | 'nginx' | 'caddy'\n const webDef = SERVICE_REGISTRY.get(webId);\n if (webDef) {\n services[webId] = buildComposeService(webDef, state);\n }\n\n // 1.5. Landing page — branded catch-all replacing traefik/whoami.\n // Prevents internal infrastructure info leakage (container IDs, IPs, headers).\n // Built from ./landing (Dockerfile + nginx.conf + index.html).\n if (webId === 'traefik') {\n const isQuickTunnel = state.domain.cloudflare.tunnelMode === 'quick';\n const landingLabels: Record<string, string> = {\n 'traefik.enable': 'true',\n 'traefik.http.services.brewnet-landing.loadbalancer.server.port': '80',\n // Security headers middleware\n 'traefik.http.middlewares.landing-headers.headers.customResponseHeaders.Server': 'Brewnet',\n 'traefik.http.middlewares.landing-headers.headers.frameDeny': 'true',\n 'traefik.http.middlewares.landing-headers.headers.contentTypeNosniff': 'true',\n 'traefik.http.middlewares.landing-headers.headers.browserXssFilter': 'true',\n };\n\n if (isQuickTunnel) {\n // Quick Tunnel: catch-all path prefix with lowest priority\n // (more specific paths like /git, /files take precedence via Traefik's rule-length priority)\n landingLabels['traefik.http.routers.brewnet-landing.rule'] = 'PathPrefix(`/`)';\n landingLabels['traefik.http.routers.brewnet-landing.entrypoints'] = 'web';\n landingLabels['traefik.http.routers.brewnet-landing.priority'] = '1';\n landingLabels['traefik.http.routers.brewnet-landing.middlewares'] = 'landing-headers@docker';\n } else {\n // Local / Named Tunnel: respond to Host: localhost requests\n landingLabels['traefik.http.routers.brewnet-landing.rule'] = 'Host(`localhost`)';\n landingLabels['traefik.http.routers.brewnet-landing.entrypoints'] = 'web';\n landingLabels['traefik.http.routers.brewnet-landing.middlewares'] = 'landing-headers@docker';\n }\n\n services['brewnet-landing'] = {\n build: './landing',\n container_name: 'brewnet-landing',\n restart: 'unless-stopped',\n security_opt: ['no-new-privileges:true'],\n networks: ['brewnet'],\n labels: landingLabels,\n logging: getLoggingConfig(),\n };\n }\n\n // 2. Git server (required, always enabled)\n const giteaDef = SERVICE_REGISTRY.get('gitea');\n if (giteaDef) {\n services['gitea'] = buildComposeService(giteaDef, state);\n }\n\n // 3. Database (optional)\n if (state.servers.dbServer.enabled && state.servers.dbServer.primary) {\n const dbId = state.servers.dbServer.primary; // 'postgresql' | 'mysql'\n const dbDef = SERVICE_REGISTRY.get(dbId);\n if (dbDef) {\n const ver = state.servers.dbServer.primaryVersion;\n const versionedImage =\n ver && dbId === 'postgresql' ? `postgres:${ver}-alpine`\n : ver && dbId === 'mysql' ? `mysql:${ver}`\n : dbDef.image;\n services[dbId] = buildComposeService({ ...dbDef, image: versionedImage }, state);\n }\n\n // Admin UI (pgadmin only for postgresql)\n if (state.servers.dbServer.adminUI && dbId === 'postgresql') {\n const pgadminDef = SERVICE_REGISTRY.get('pgadmin');\n if (pgadminDef) {\n services['pgadmin'] = buildComposeService(pgadminDef, state);\n }\n }\n }\n\n // 4. File server (optional)\n if (state.servers.fileServer.enabled && state.servers.fileServer.service) {\n const fileId = state.servers.fileServer.service; // 'nextcloud' | 'minio'\n const fileDef = SERVICE_REGISTRY.get(fileId);\n if (fileDef) {\n services[fileId] = buildComposeService(fileDef, state);\n }\n }\n\n // 5. Media (optional)\n if (state.servers.media.enabled && state.servers.media.services.length > 0) {\n for (const mediaId of state.servers.media.services) {\n const mediaDef = SERVICE_REGISTRY.get(mediaId);\n if (mediaDef) {\n services[mediaId] = buildComposeService(mediaDef, state);\n }\n }\n }\n\n // 6. SSH server (optional)\n if (state.servers.sshServer.enabled) {\n const sshDef = SERVICE_REGISTRY.get('openssh-server');\n if (sshDef) {\n services['openssh-server'] = buildComposeService(sshDef, state);\n }\n }\n\n // 7. Cloudflare tunnel (optional)\n if (state.domain.cloudflare.enabled) {\n const cfDef = SERVICE_REGISTRY.get('cloudflared');\n if (cfDef) {\n services['cloudflared'] = buildComposeService(cfDef, state);\n }\n }\n\n // 9. FileBrowser (optional)\n if (state.servers.fileBrowser.enabled) {\n const fbDef = SERVICE_REGISTRY.get('filebrowser');\n if (fbDef) {\n services['filebrowser'] = buildComposeService(fbDef, state);\n }\n }\n\n // ── Apply Docker secrets migration to all services ──────────────────\n for (const [id, svc] of Object.entries(services)) {\n applySecretsMigration(id, svc, state);\n }\n\n const namedVolumes = collectNamedVolumes(services);\n const topSecrets = collectTopLevelSecrets(services);\n\n return {\n name: state.projectName || 'brewnet',\n services,\n networks: {\n brewnet: { external: true },\n 'brewnet-internal': { internal: true },\n },\n ...(Object.keys(namedVolumes).length > 0 ? { volumes: namedVolumes } : {}),\n ...(topSecrets ? { secrets: topSecrets } : {}),\n };\n}\n\n// ---------------------------------------------------------------------------\n// External domain label helpers (Traefik Host-based routing for Cloudflare Tunnel)\n// ---------------------------------------------------------------------------\n\n/**\n * Add Traefik external Host-based routing labels for a service in docker-compose.yml.\n *\n * Reads the existing compose file, adds `-external` router labels to the target service,\n * and writes back. These labels enable Traefik to route incoming requests\n * for `hostname` to the correct container.\n */\nexport function addExternalLabels(\n composePath: string,\n appName: string,\n hostname: string,\n port: number,\n): void {\n const raw = readFileSync(composePath, 'utf-8');\n const doc = yaml.load(raw) as Record<string, unknown>;\n const services = doc['services'] as Record<string, Record<string, unknown>> | undefined;\n if (!services) return;\n\n // Find the service — try exact name, then brewnet-prefixed\n const serviceKey = services[appName] ? appName\n : services[`brewnet-${appName}`] ? `brewnet-${appName}`\n : Object.keys(services).find((k) => {\n const cn = (services[k] as Record<string, unknown>)?.['container_name'];\n return cn === appName || cn === `brewnet-${appName}`;\n });\n\n if (!serviceKey) {\n throw new Error(`Service \"${appName}\" not found in docker-compose.yml`);\n }\n\n const svc = services[serviceKey] as Record<string, unknown>;\n const labels = (svc['labels'] ?? {}) as Record<string, string>;\n\n const routerName = `${appName}-external`;\n labels['traefik.enable'] = 'true';\n labels[`traefik.http.routers.${routerName}.rule`] = `Host(\\`${hostname}\\`)`;\n labels[`traefik.http.routers.${routerName}.entrypoints`] = 'web';\n labels[`traefik.http.routers.${routerName}.service`] = routerName;\n labels[`traefik.http.services.${routerName}.loadbalancer.server.port`] = String(port);\n\n svc['labels'] = labels;\n\n const output = yaml.dump(doc, {\n indent: 2,\n lineWidth: 120,\n noRefs: true,\n sortKeys: false,\n quotingType: '\"',\n forceQuotes: false,\n });\n writeFileSync(composePath, output, 'utf-8');\n}\n\n/**\n * Remove Traefik external routing labels for a service from docker-compose.yml.\n */\nexport function removeExternalLabels(\n composePath: string,\n appName: string,\n): void {\n const raw = readFileSync(composePath, 'utf-8');\n const doc = yaml.load(raw) as Record<string, unknown>;\n const services = doc['services'] as Record<string, Record<string, unknown>> | undefined;\n if (!services) return;\n\n const serviceKey = services[appName] ? appName\n : services[`brewnet-${appName}`] ? `brewnet-${appName}`\n : Object.keys(services).find((k) => {\n const cn = (services[k] as Record<string, unknown>)?.['container_name'];\n return cn === appName || cn === `brewnet-${appName}`;\n });\n\n if (!serviceKey) return; // Nothing to remove\n\n const svc = services[serviceKey] as Record<string, unknown>;\n const labels = svc['labels'] as Record<string, string> | undefined;\n if (!labels) return;\n\n const routerName = `${appName}-external`;\n const prefix = `traefik.http.routers.${routerName}`;\n const svcPrefix = `traefik.http.services.${routerName}`;\n\n for (const key of Object.keys(labels)) {\n if (key.startsWith(prefix) || key.startsWith(svcPrefix)) {\n delete labels[key];\n }\n }\n\n const output = yaml.dump(doc, {\n indent: 2,\n lineWidth: 120,\n noRefs: true,\n sortKeys: false,\n quotingType: '\"',\n forceQuotes: false,\n });\n writeFileSync(composePath, output, 'utf-8');\n}\n\n/**\n * Add Traefik Quick Tunnel PathPrefix routing labels + brewnet network to a\n * boilerplate / app docker-compose.yml so that the service is accessible\n * externally via the Quick Tunnel URL at /apps/{appName}.\n *\n * - Injects `traefik.enable`, PathPrefix rule, strip-prefix middleware\n * - Adds `brewnet` as an external network and attaches it to the target service\n *\n * @param composePath - Absolute path to docker-compose.yml\n * @param appName - Logical app name used as path segment (e.g. \"node-nest\")\n * @param serviceName - Docker Compose service key to label (e.g. \"backend\", \"app\")\n * @param port - Container HTTP port\n */\nexport function addQuickTunnelAppLabels(\n composePath: string,\n appName: string,\n serviceName: string,\n port: number,\n noStrip?: boolean,\n): void {\n const raw = readFileSync(composePath, 'utf-8');\n const doc = yaml.load(raw) as Record<string, unknown>;\n const services = doc['services'] as Record<string, Record<string, unknown>> | undefined;\n if (!services) return;\n\n const svc = services[serviceName];\n if (!svc) return;\n\n // --- Traefik labels ---\n // Convert array-style labels ([\"key=val\"]) to object format ({key: \"val\"})\n // Boilerplate compose files use array-style; Traefik needs object-style.\n const routerName = `app-${appName}`;\n const pathPrefix = `/apps/${appName}`;\n let labels: Record<string, string>;\n const rawLabels = svc['labels'];\n if (Array.isArray(rawLabels)) {\n labels = {};\n for (const l of rawLabels) {\n const s = String(l);\n const idx = s.indexOf('=');\n if (idx > 0) labels[s.slice(0, idx)] = s.slice(idx + 1);\n }\n } else {\n labels = (rawLabels ?? {}) as Record<string, string>;\n }\n labels['traefik.enable'] = 'true';\n labels[`traefik.http.routers.${routerName}.rule`] = `PathPrefix(\\`${pathPrefix}\\`)`;\n labels[`traefik.http.routers.${routerName}.entrypoints`] = 'web';\n // Priority based on path length — longer paths get higher priority to avoid\n // /apps/nodejs-nestjs matching /apps/nodejs-nestjs-ui before the -ui router\n labels[`traefik.http.routers.${routerName}.priority`] = String(10 + pathPrefix.length);\n labels[`traefik.http.routers.${routerName}.service`] = routerName;\n labels[`traefik.http.services.${routerName}.loadbalancer.server.port`] = String(port);\n // Trailing slash redirect — essential for Vite/React SPA frontends.\n // Without trailing slash, relative asset paths (./assets/...) resolve to\n // the parent directory, bypassing Traefik's PathPrefix route.\n // Uses $$ to escape $ in Docker Compose (prevents variable interpolation).\n labels[`traefik.http.middlewares.${routerName}-slash.redirectregex.regex`] =\n `^(.*${pathPrefix.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')})$$`;\n labels[`traefik.http.middlewares.${routerName}-slash.redirectregex.replacement`] =\n '$$' + '{1}/';\n labels[`traefik.http.middlewares.${routerName}-slash.redirectregex.permanent`] = 'false';\n if (noStrip) {\n // Next.js with basePath handles sub-path routing internally.\n // No strip-prefix needed (basePath handles the prefix).\n // No trailing-slash redirect needed (conflicts with Next.js default trailingSlash:false,\n // causing a redirect loop: Traefik adds slash → Next.js removes slash → loop).\n } else {\n // Strip /apps/{appName} prefix before forwarding to the container\n labels[`traefik.http.middlewares.${routerName}-strip.stripprefix.prefixes`] = pathPrefix;\n labels[`traefik.http.routers.${routerName}.middlewares`] = `${routerName}-slash,${routerName}-strip`;\n }\n svc['labels'] = labels;\n\n // --- brewnet external network ---\n const svcNetworks = (svc['networks'] ?? []) as string[] | Record<string, unknown>;\n if (Array.isArray(svcNetworks)) {\n if (!svcNetworks.includes('brewnet')) svcNetworks.push('brewnet');\n svc['networks'] = svcNetworks;\n } else {\n if (!svcNetworks['brewnet']) svcNetworks['brewnet'] = {};\n svc['networks'] = svcNetworks;\n }\n\n // Top-level networks — FORCE brewnet to external: true\n // Boilerplate compose files may declare brewnet as { driver: bridge } which creates\n // a separate project-scoped network (e.g. nodejs-nestjs_brewnet) instead of using\n // the pre-existing global 'brewnet' network that Traefik monitors.\n const topNetworks = (doc['networks'] ?? {}) as Record<string, unknown>;\n topNetworks['brewnet'] = { external: true };\n doc['networks'] = topNetworks;\n\n const output = yaml.dump(doc, {\n indent: 2,\n lineWidth: 120,\n noRefs: true,\n sortKeys: false,\n quotingType: '\"',\n forceQuotes: false,\n });\n writeFileSync(composePath, output, 'utf-8');\n}\n\n/**\n * Patch an existing docker-compose.yml to switch cloudflared from Quick Tunnel\n * to Named Tunnel mode.\n *\n * Reads the compose file, updates the cloudflared service:\n * - command: ['tunnel', '--no-autoupdate', 'run'] (removes --url flag)\n * - environment.TUNNEL_TOKEN: tunnelToken\n *\n * Returns true if the file was updated, false if cloudflared service was not\n * found (caller should warn the user to update manually).\n */\nexport function patchCloudflaredToNamedTunnel(\n composePath: string,\n tunnelToken: string,\n): boolean {\n const raw = readFileSync(composePath, 'utf-8');\n const doc = yaml.load(raw) as Record<string, unknown>;\n const services = doc['services'] as Record<string, Record<string, unknown>> | undefined;\n if (!services?.['cloudflared']) return false;\n\n const svc = services['cloudflared'] as Record<string, unknown>;\n\n // Switch to named tunnel command (replaces quick-tunnel --url form)\n svc['command'] = ['tunnel', '--no-autoupdate', 'run'];\n\n // Set TUNNEL_TOKEN in environment\n const env = (svc['environment'] as Record<string, string> | undefined) ?? {};\n env['TUNNEL_TOKEN'] = tunnelToken;\n svc['environment'] = env;\n\n const output = yaml.dump(doc, {\n indent: 2,\n lineWidth: 120,\n noRefs: true,\n sortKeys: false,\n });\n writeFileSync(composePath, output, 'utf-8');\n return true;\n}\n\n/**\n * Serialize a ComposeConfig to a YAML string suitable for writing to\n * docker-compose.yml.\n */\nexport function composeConfigToYaml(config: ComposeConfig): string {\n return yaml.dump(config, {\n indent: 2,\n lineWidth: 120,\n noRefs: true,\n sortKeys: false,\n quotingType: '\"',\n forceQuotes: false,\n });\n}\n","/**\n * @module services\n * @description Service registry for all Docker services managed by Brewnet.\n * Each entry defines the container image, resource requirements, networking,\n * health checks, required environment variables, and Traefik routing labels.\n *\n * Task: T019 — Phase 2 Config Registries\n */\n\nexport interface ServiceDefinition {\n id: string;\n name: string;\n image: string;\n ports: number[];\n subdomain: string;\n ramMB: number;\n diskGB: number;\n networks: ('brewnet' | 'brewnet-internal')[];\n healthCheck?: {\n endpoint: string;\n interval: number;\n timeout: number;\n retries: number;\n };\n requiredEnvVars: string[];\n traefikLabels?: Record<string, string>;\n}\n\n// ---------------------------------------------------------------------------\n// Helper to build Traefik HTTP router labels for a given subdomain.\n// The domain placeholder `{{DOMAIN}}` is resolved at compose-generation time.\n// ---------------------------------------------------------------------------\nfunction traefikRouterLabels(\n serviceId: string,\n subdomain: string,\n port: number,\n): Record<string, string> {\n return {\n 'traefik.enable': 'true',\n [`traefik.http.routers.${serviceId}.rule`]: `Host(\\`${subdomain}.{{DOMAIN}}\\`)`,\n [`traefik.http.routers.${serviceId}.entrypoints`]: 'websecure',\n [`traefik.http.routers.${serviceId}.tls.certresolver`]: 'letsencrypt',\n [`traefik.http.services.${serviceId}.loadbalancer.server.port`]: String(port),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Registry\n// ---------------------------------------------------------------------------\n\nexport const SERVICE_REGISTRY: Map<string, ServiceDefinition> = new Map([\n // -- Web servers ----------------------------------------------------------\n [\n 'traefik',\n {\n id: 'traefik',\n name: 'Traefik',\n image: 'traefik:v2.11',\n ports: [80, 443, 8080],\n subdomain: 'traefik',\n ramMB: 64,\n diskGB: 0.1,\n networks: ['brewnet'],\n healthCheck: {\n endpoint: '/api/health',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: [],\n traefikLabels: {\n 'traefik.enable': 'true',\n 'traefik.http.routers.traefik-dashboard.rule': 'Host(`traefik.{{DOMAIN}}`)',\n 'traefik.http.routers.traefik-dashboard.entrypoints': 'websecure',\n 'traefik.http.routers.traefik-dashboard.tls.certresolver': 'letsencrypt',\n 'traefik.http.routers.traefik-dashboard.service': 'api@internal',\n },\n },\n ],\n [\n 'nginx',\n {\n id: 'nginx',\n name: 'Nginx',\n image: 'nginx:1.25-alpine',\n ports: [80, 443],\n subdomain: '',\n ramMB: 32,\n diskGB: 0.1,\n networks: ['brewnet'],\n healthCheck: {\n endpoint: '/',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: [],\n },\n ],\n [\n 'caddy',\n {\n id: 'caddy',\n name: 'Caddy',\n image: 'caddy:2-alpine',\n ports: [80, 443],\n subdomain: '',\n ramMB: 32,\n diskGB: 0.1,\n networks: ['brewnet'],\n healthCheck: {\n endpoint: '/',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: [],\n },\n ],\n\n // -- Git server -----------------------------------------------------------\n [\n 'gitea',\n {\n id: 'gitea',\n name: 'Gitea',\n image: 'gitea/gitea:latest',\n ports: [3000, 3022],\n subdomain: 'git',\n ramMB: 256,\n diskGB: 1,\n networks: ['brewnet', 'brewnet-internal'],\n healthCheck: {\n endpoint: '/api/healthz',\n interval: 30,\n timeout: 10,\n retries: 3,\n },\n requiredEnvVars: [\n 'GITEA__database__DB_TYPE',\n 'GITEA__database__HOST',\n 'GITEA__database__NAME',\n 'GITEA__database__USER',\n 'GITEA__database__PASSWD',\n ],\n traefikLabels: traefikRouterLabels('gitea', 'git', 3000),\n },\n ],\n\n // -- File browser ---------------------------------------------------------\n [\n 'filebrowser',\n {\n id: 'filebrowser',\n name: 'FileBrowser',\n image: 'filebrowser/filebrowser:latest',\n ports: [80],\n subdomain: 'files',\n ramMB: 50,\n diskGB: 0.1,\n networks: ['brewnet'],\n healthCheck: {\n endpoint: '/health',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: [],\n traefikLabels: traefikRouterLabels('filebrowser', 'files', 80),\n },\n ],\n\n // -- File servers ---------------------------------------------------------\n [\n 'nextcloud',\n {\n id: 'nextcloud',\n name: 'Nextcloud',\n image: 'nextcloud:29-apache',\n ports: [80],\n subdomain: 'cloud',\n ramMB: 256,\n diskGB: 2,\n networks: ['brewnet', 'brewnet-internal'],\n healthCheck: {\n endpoint: '/status.php',\n interval: 30,\n timeout: 10,\n retries: 5,\n },\n requiredEnvVars: [\n 'NEXTCLOUD_ADMIN_USER',\n 'NEXTCLOUD_ADMIN_PASSWORD',\n 'NEXTCLOUD_TRUSTED_DOMAINS',\n ],\n traefikLabels: traefikRouterLabels('nextcloud', 'cloud', 80),\n },\n ],\n [\n 'minio',\n {\n id: 'minio',\n name: 'MinIO',\n image: 'minio/minio:latest',\n ports: [9000],\n subdomain: 'minio',\n ramMB: 128,\n diskGB: 1,\n networks: ['brewnet', 'brewnet-internal'],\n healthCheck: {\n endpoint: '/minio/health/live',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: ['MINIO_ROOT_USER', 'MINIO_ROOT_PASSWORD'],\n traefikLabels: traefikRouterLabels('minio', 'minio', 9001),\n },\n ],\n\n // -- Media ----------------------------------------------------------------\n [\n 'jellyfin',\n {\n id: 'jellyfin',\n name: 'Jellyfin',\n image: 'jellyfin/jellyfin:latest',\n ports: [8096],\n subdomain: 'jellyfin',\n ramMB: 256,\n diskGB: 2,\n networks: ['brewnet'],\n healthCheck: {\n endpoint: '/health',\n interval: 30,\n timeout: 10,\n retries: 3,\n },\n requiredEnvVars: [],\n traefikLabels: traefikRouterLabels('jellyfin', 'jellyfin', 8096),\n },\n ],\n\n // -- Databases ------------------------------------------------------------\n [\n 'postgresql',\n {\n id: 'postgresql',\n name: 'PostgreSQL',\n image: 'postgres:18.3-alpine',\n ports: [5432],\n subdomain: '',\n ramMB: 120,\n diskGB: 1,\n networks: ['brewnet', 'brewnet-internal'],\n healthCheck: {\n endpoint: '',\n interval: 10,\n timeout: 5,\n retries: 5,\n },\n requiredEnvVars: ['POSTGRES_PASSWORD', 'POSTGRES_USER', 'POSTGRES_DB'],\n },\n ],\n [\n 'mysql',\n {\n id: 'mysql',\n name: 'MySQL',\n image: 'mysql:8.4',\n ports: [3306],\n subdomain: '',\n ramMB: 256,\n diskGB: 1,\n networks: ['brewnet', 'brewnet-internal'],\n healthCheck: {\n endpoint: '',\n interval: 10,\n timeout: 5,\n retries: 5,\n },\n requiredEnvVars: [\n 'MYSQL_ROOT_PASSWORD',\n 'MYSQL_DATABASE',\n 'MYSQL_USER',\n 'MYSQL_PASSWORD',\n ],\n },\n ],\n\n // -- Admin UI -------------------------------------------------------------\n [\n 'pgadmin',\n {\n id: 'pgadmin',\n name: 'pgAdmin',\n image: 'dpage/pgadmin4:latest',\n ports: [5050],\n subdomain: 'pgadmin',\n ramMB: 128,\n diskGB: 0.5,\n networks: ['brewnet', 'brewnet-internal'],\n healthCheck: {\n endpoint: '/misc/ping',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: [\n 'PGADMIN_DEFAULT_EMAIL',\n 'PGADMIN_DEFAULT_PASSWORD',\n ],\n traefikLabels: traefikRouterLabels('pgadmin', 'pgadmin', 80),\n },\n ],\n\n // -- SSH ------------------------------------------------------------------\n [\n 'openssh-server',\n {\n id: 'openssh-server',\n name: 'OpenSSH Server',\n image: 'linuxserver/openssh-server:latest',\n ports: [2222],\n subdomain: '',\n ramMB: 16,\n diskGB: 0.1,\n networks: ['brewnet'],\n healthCheck: {\n endpoint: '',\n interval: 30,\n timeout: 5,\n retries: 3,\n },\n requiredEnvVars: ['PUID', 'PGID', 'TZ', 'PASSWORD_ACCESS', 'USER_NAME'],\n },\n ],\n\n // -- Tunnel ---------------------------------------------------------------\n [\n 'cloudflared',\n {\n id: 'cloudflared',\n name: 'Cloudflare Tunnel',\n image: 'cloudflare/cloudflared:latest',\n ports: [],\n subdomain: '',\n ramMB: 32,\n diskGB: 0.1,\n networks: ['brewnet'],\n requiredEnvVars: ['TUNNEL_TOKEN'],\n },\n ],\n]);\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Retrieve a single service definition by its unique ID.\n */\nexport function getServiceDefinition(id: string): ServiceDefinition | undefined {\n return SERVICE_REGISTRY.get(id);\n}\n\n/**\n * Return a sorted array of every registered service ID.\n */\nexport function getAllServiceIds(): string[] {\n return [...SERVICE_REGISTRY.keys()];\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config/frameworks.ts
|
|
4
|
+
var LANGUAGE_REGISTRY = {
|
|
5
|
+
python: {
|
|
6
|
+
name: "Python",
|
|
7
|
+
frameworks: [
|
|
8
|
+
{ id: "fastapi", name: "FastAPI", description: "Modern async web framework" },
|
|
9
|
+
{ id: "django", name: "Django", description: "Full-featured web framework" },
|
|
10
|
+
{ id: "flask", name: "Flask", description: "Lightweight micro-framework" }
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
nodejs: {
|
|
14
|
+
name: "Node.js",
|
|
15
|
+
frameworks: [
|
|
16
|
+
{ id: "nextjs", name: "Next.js (Full-Stack)", description: "Server Components + Client Components + API Routes \u2014 full-stack in one project" },
|
|
17
|
+
{ id: "nextjs-app", name: "Next.js (API Routes)", description: "API Routes as backend \u2014 minimal UI, CORS-free, fast MVP" },
|
|
18
|
+
{ id: "express", name: "Express", description: "Minimal web framework" },
|
|
19
|
+
{ id: "nestjs", name: "NestJS", description: "Progressive Node.js framework" }
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
java: {
|
|
23
|
+
name: "Java",
|
|
24
|
+
frameworks: [
|
|
25
|
+
{ id: "spring", name: "Spring Framework", description: "Enterprise Java framework" },
|
|
26
|
+
{ id: "springboot", name: "Spring Boot", description: "Opinionated Spring, production-ready (recommended)" }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
rust: {
|
|
30
|
+
name: "Rust",
|
|
31
|
+
frameworks: [
|
|
32
|
+
{ id: "axum", name: "Axum", description: "Ergonomic and modular framework (MIT)" },
|
|
33
|
+
{ id: "actix-web", name: "Actix Web", description: "High performance web framework (MIT)" }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
go: {
|
|
37
|
+
name: "Go",
|
|
38
|
+
frameworks: [
|
|
39
|
+
{ id: "gin", name: "Gin", description: "HTTP web framework (MIT)" },
|
|
40
|
+
{ id: "echo", name: "Echo", description: "High performance framework (MIT)" },
|
|
41
|
+
{ id: "fiber", name: "Fiber", description: "Express-inspired framework (MIT)" }
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
kotlin: {
|
|
45
|
+
name: "Kotlin",
|
|
46
|
+
frameworks: [
|
|
47
|
+
{ id: "ktor", name: "Ktor", description: "Asynchronous Kotlin web framework (default)" },
|
|
48
|
+
{ id: "springboot-kt", name: "Spring Boot (Kotlin)", description: "Spring Boot with Kotlin DSL" }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var FRONTEND_REGISTRY = {
|
|
53
|
+
react: { name: "React (TypeScript)", description: "React SPA with Vite + TypeScript" },
|
|
54
|
+
vue: { name: "Vue.js (Vite)", description: "Vue 3 SPA with Vite build tool" },
|
|
55
|
+
none: { name: "Skip frontend", description: "No frontend framework" }
|
|
56
|
+
};
|
|
57
|
+
function getFrameworksForLanguage(language) {
|
|
58
|
+
return LANGUAGE_REGISTRY[language].frameworks;
|
|
59
|
+
}
|
|
60
|
+
function getAllLanguages() {
|
|
61
|
+
return Object.keys(LANGUAGE_REGISTRY);
|
|
62
|
+
}
|
|
63
|
+
function getAllFrontendTechs() {
|
|
64
|
+
return Object.keys(FRONTEND_REGISTRY);
|
|
65
|
+
}
|
|
66
|
+
var STACK_ID_MAP = {
|
|
67
|
+
nodejs: {
|
|
68
|
+
nextjs: "nodejs-nextjs-full",
|
|
69
|
+
// Full-Stack Next.js
|
|
70
|
+
"nextjs-app": "nodejs-nextjs",
|
|
71
|
+
// API-Routes-only Next.js
|
|
72
|
+
express: "nodejs-express",
|
|
73
|
+
nestjs: "nodejs-nestjs"
|
|
74
|
+
},
|
|
75
|
+
kotlin: {
|
|
76
|
+
ktor: "kotlin-ktor",
|
|
77
|
+
"springboot-kt": "kotlin-springboot"
|
|
78
|
+
// wizard id differs from stack id
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
function resolveStackId(language, frameworkId) {
|
|
82
|
+
const override = STACK_ID_MAP[language]?.[frameworkId];
|
|
83
|
+
if (override !== void 0) return override;
|
|
84
|
+
const candidate = `${language}-${frameworkId}`;
|
|
85
|
+
const VALID_STACK_IDS = /* @__PURE__ */ new Set([
|
|
86
|
+
"go-gin",
|
|
87
|
+
"go-echo",
|
|
88
|
+
"go-fiber",
|
|
89
|
+
"rust-actix-web",
|
|
90
|
+
"rust-axum",
|
|
91
|
+
"java-springboot",
|
|
92
|
+
"java-spring",
|
|
93
|
+
"kotlin-ktor",
|
|
94
|
+
"kotlin-springboot",
|
|
95
|
+
"nodejs-express",
|
|
96
|
+
"nodejs-nestjs",
|
|
97
|
+
"nodejs-nextjs",
|
|
98
|
+
"nodejs-nextjs-full",
|
|
99
|
+
"python-fastapi",
|
|
100
|
+
"python-django",
|
|
101
|
+
"python-flask"
|
|
102
|
+
]);
|
|
103
|
+
return VALID_STACK_IDS.has(candidate) ? candidate : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export {
|
|
107
|
+
LANGUAGE_REGISTRY,
|
|
108
|
+
FRONTEND_REGISTRY,
|
|
109
|
+
getFrameworksForLanguage,
|
|
110
|
+
getAllLanguages,
|
|
111
|
+
getAllFrontendTechs,
|
|
112
|
+
resolveStackId
|
|
113
|
+
};
|
|
114
|
+
//# sourceMappingURL=chunk-BAVGYMGA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config/frameworks.ts"],"sourcesContent":["/**\n * @module frameworks\n * @description Language, framework, and frontend technology registries\n * used by the Brewnet wizard (Step 3: Dev Stack & Runtime).\n *\n * Task: T020 — Phase 2 Config Registries\n */\n\nexport type Language = 'python' | 'nodejs' | 'java' | 'rust' | 'go' | 'kotlin';\n\nexport type FrontendTech = 'react' | 'vue' | 'none';\n\nexport interface FrameworkOption {\n id: string;\n name: string;\n description: string;\n}\n\n// ---------------------------------------------------------------------------\n// Language → Frameworks registry\n// ---------------------------------------------------------------------------\n\nexport const LANGUAGE_REGISTRY: Record<Language, { name: string; frameworks: FrameworkOption[] }> = {\n python: {\n name: 'Python',\n frameworks: [\n { id: 'fastapi', name: 'FastAPI', description: 'Modern async web framework' },\n { id: 'django', name: 'Django', description: 'Full-featured web framework' },\n { id: 'flask', name: 'Flask', description: 'Lightweight micro-framework' },\n ],\n },\n nodejs: {\n name: 'Node.js',\n frameworks: [\n { id: 'nextjs', name: 'Next.js (Full-Stack)', description: 'Server Components + Client Components + API Routes — full-stack in one project' },\n { id: 'nextjs-app', name: 'Next.js (API Routes)', description: 'API Routes as backend — minimal UI, CORS-free, fast MVP' },\n { id: 'express', name: 'Express', description: 'Minimal web framework' },\n { id: 'nestjs', name: 'NestJS', description: 'Progressive Node.js framework' },\n ],\n },\n java: {\n name: 'Java',\n frameworks: [\n { id: 'spring', name: 'Spring Framework', description: 'Enterprise Java framework' },\n { id: 'springboot', name: 'Spring Boot', description: 'Opinionated Spring, production-ready (recommended)' },\n ],\n },\n rust: {\n name: 'Rust',\n frameworks: [\n { id: 'axum', name: 'Axum', description: 'Ergonomic and modular framework (MIT)' },\n { id: 'actix-web', name: 'Actix Web', description: 'High performance web framework (MIT)' },\n ],\n },\n go: {\n name: 'Go',\n frameworks: [\n { id: 'gin', name: 'Gin', description: 'HTTP web framework (MIT)' },\n { id: 'echo', name: 'Echo', description: 'High performance framework (MIT)' },\n { id: 'fiber', name: 'Fiber', description: 'Express-inspired framework (MIT)' },\n ],\n },\n kotlin: {\n name: 'Kotlin',\n frameworks: [\n { id: 'ktor', name: 'Ktor', description: 'Asynchronous Kotlin web framework (default)' },\n { id: 'springboot-kt', name: 'Spring Boot (Kotlin)', description: 'Spring Boot with Kotlin DSL' },\n ],\n },\n};\n\n// ---------------------------------------------------------------------------\n// Frontend technologies registry\n// ---------------------------------------------------------------------------\n\nexport const FRONTEND_REGISTRY: Record<FrontendTech, { name: string; description: string }> = {\n react: { name: 'React (TypeScript)', description: 'React SPA with Vite + TypeScript' },\n vue: { name: 'Vue.js (Vite)', description: 'Vue 3 SPA with Vite build tool' },\n none: { name: 'Skip frontend', description: 'No frontend framework' },\n};\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Return the list of available frameworks for a given language.\n */\nexport function getFrameworksForLanguage(language: Language): FrameworkOption[] {\n return LANGUAGE_REGISTRY[language].frameworks;\n}\n\n/**\n * Return all registered language keys.\n */\nexport function getAllLanguages(): Language[] {\n return Object.keys(LANGUAGE_REGISTRY) as Language[];\n}\n\n/**\n * Return all registered frontend technology keys.\n */\nexport function getAllFrontendTechs(): FrontendTech[] {\n return Object.keys(FRONTEND_REGISTRY) as FrontendTech[];\n}\n\n// ---------------------------------------------------------------------------\n// Stack ID resolution (wizard devStack → CONNECT_BOILERPLATE.md stackId)\n// ---------------------------------------------------------------------------\n\n/**\n * Maps wizard framework IDs to their CONNECT_BOILERPLATE.md stack IDs.\n * Most follow the pattern `<language>-<frameworkId>`, but these exceptions differ.\n */\nconst STACK_ID_MAP: Partial<Record<string, Partial<Record<string, string>>>> = {\n nodejs: {\n nextjs: 'nodejs-nextjs-full', // Full-Stack Next.js\n 'nextjs-app': 'nodejs-nextjs', // API-Routes-only Next.js\n express: 'nodejs-express',\n nestjs: 'nodejs-nestjs',\n },\n kotlin: {\n ktor: 'kotlin-ktor',\n 'springboot-kt': 'kotlin-springboot', // wizard id differs from stack id\n },\n};\n\n/**\n * Resolve a wizard (language, frameworkId) pair to a CONNECT_BOILERPLATE.md stack ID.\n * Returns null when the combination has no corresponding stack.\n *\n * Examples:\n * resolveStackId('python', 'fastapi') → 'python-fastapi'\n * resolveStackId('nodejs', 'nextjs') → 'nodejs-nextjs-full'\n * resolveStackId('nodejs', 'nextjs-app') → 'nodejs-nextjs'\n * resolveStackId('kotlin', 'springboot-kt') → 'kotlin-springboot'\n * resolveStackId('go', 'gin') → 'go-gin'\n */\nexport function resolveStackId(language: string, frameworkId: string): string | null {\n const override = STACK_ID_MAP[language]?.[frameworkId];\n if (override !== undefined) return override;\n\n // Default pattern: <language>-<frameworkId>\n const candidate = `${language}-${frameworkId}`;\n\n // Validate against known CONNECT_BOILERPLATE.md stack IDs\n const VALID_STACK_IDS = new Set([\n 'go-gin', 'go-echo', 'go-fiber',\n 'rust-actix-web', 'rust-axum',\n 'java-springboot', 'java-spring',\n 'kotlin-ktor', 'kotlin-springboot',\n 'nodejs-express', 'nodejs-nestjs', 'nodejs-nextjs', 'nodejs-nextjs-full',\n 'python-fastapi', 'python-django', 'python-flask',\n ]);\n\n return VALID_STACK_IDS.has(candidate) ? candidate : null;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
addQuickTunnelAppLabels
|
|
4
|
+
} from "./chunk-4TJMJZMO.js";
|
|
5
|
+
import {
|
|
6
|
+
BOILERPLATE_REPO_URL
|
|
7
|
+
} from "./chunk-HCHY5UIQ.js";
|
|
8
|
+
|
|
9
|
+
// src/services/boilerplate-manager.ts
|
|
10
|
+
import { readFileSync, writeFileSync, rmSync, readdirSync, statSync, existsSync } from "fs";
|
|
11
|
+
import { join, extname } from "path";
|
|
12
|
+
import { randomBytes } from "crypto";
|
|
13
|
+
import { createServer } from "net";
|
|
14
|
+
import { execa } from "execa";
|
|
15
|
+
import yaml from "js-yaml";
|
|
16
|
+
var RESERVED_PORTS = /* @__PURE__ */ new Set([8088]);
|
|
17
|
+
async function findFreePort(start) {
|
|
18
|
+
for (let port = start; port < start + 40; port++) {
|
|
19
|
+
if (RESERVED_PORTS.has(port)) continue;
|
|
20
|
+
const free = await new Promise((resolve) => {
|
|
21
|
+
const srv = createServer();
|
|
22
|
+
srv.once("error", () => resolve(false));
|
|
23
|
+
srv.once("listening", () => {
|
|
24
|
+
srv.close(() => resolve(true));
|
|
25
|
+
});
|
|
26
|
+
srv.listen(port, "0.0.0.0");
|
|
27
|
+
});
|
|
28
|
+
if (free) return port;
|
|
29
|
+
}
|
|
30
|
+
return start;
|
|
31
|
+
}
|
|
32
|
+
async function cloneStack(stackId, projectDir) {
|
|
33
|
+
const { existsSync: dirExists, rmSync: rmSync2 } = await import("fs");
|
|
34
|
+
if (dirExists(projectDir)) {
|
|
35
|
+
rmSync2(projectDir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
await execa("git", [
|
|
38
|
+
"clone",
|
|
39
|
+
"--depth=1",
|
|
40
|
+
"-b",
|
|
41
|
+
`stack/${stackId}`,
|
|
42
|
+
BOILERPLATE_REPO_URL,
|
|
43
|
+
projectDir
|
|
44
|
+
]);
|
|
45
|
+
if (stackId.startsWith("nodejs-nextjs")) {
|
|
46
|
+
patchImagePaths(projectDir, "/brewnet-site-banner.png");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function patchImagePaths(dir, replacement, search = "./brewnet-site-banner.png") {
|
|
50
|
+
const jsxExts = /* @__PURE__ */ new Set([".tsx", ".ts", ".jsx", ".js"]);
|
|
51
|
+
const needle = `src="${search}"`;
|
|
52
|
+
const insert = `src="${replacement}"`;
|
|
53
|
+
const walk = (d) => {
|
|
54
|
+
for (const entry of readdirSync(d)) {
|
|
55
|
+
if (entry === "node_modules" || entry === ".git" || entry === "dist") continue;
|
|
56
|
+
const full = join(d, entry);
|
|
57
|
+
if (statSync(full).isDirectory()) {
|
|
58
|
+
walk(full);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!jsxExts.has(extname(entry))) continue;
|
|
62
|
+
const original = readFileSync(full, "utf-8");
|
|
63
|
+
const patched = original.replaceAll(needle, insert);
|
|
64
|
+
if (patched !== original) writeFileSync(full, patched, "utf-8");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(dir);
|
|
68
|
+
}
|
|
69
|
+
async function reinitGit(projectDir) {
|
|
70
|
+
rmSync(join(projectDir, ".git"), { recursive: true, force: true });
|
|
71
|
+
await execa("git", ["init"], { cwd: projectDir });
|
|
72
|
+
await execa("git", ["add", "-A"], { cwd: projectDir });
|
|
73
|
+
await execa("git", ["commit", "-m", "chore: initial project from brewnet create-app"], {
|
|
74
|
+
cwd: projectDir
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
var PRISMA_PROVIDER = {
|
|
78
|
+
postgres: "postgresql",
|
|
79
|
+
mysql: "mysql",
|
|
80
|
+
sqlite3: "sqlite"
|
|
81
|
+
};
|
|
82
|
+
function buildPrismaDatabaseUrl(dbDriver, dbUser, dbPassword, dbName) {
|
|
83
|
+
switch (dbDriver) {
|
|
84
|
+
case "postgres":
|
|
85
|
+
return `postgresql://${dbUser}:${dbPassword}@postgres:5432/${dbName}`;
|
|
86
|
+
case "mysql":
|
|
87
|
+
return `mysql://${dbUser}:${dbPassword}@mysql:3306/${dbName}`;
|
|
88
|
+
default:
|
|
89
|
+
return "file:/app/data/brewnet_db.db";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function generateEnv(projectDir, stackId, dbDriver, opts) {
|
|
93
|
+
const examplePath = join(projectDir, ".env.example");
|
|
94
|
+
const envPath = join(projectDir, ".env");
|
|
95
|
+
let content = readFileSync(examplePath, "utf-8");
|
|
96
|
+
const dbUser = opts?.dbUser ?? "brewnet";
|
|
97
|
+
const dbName = opts?.dbName ?? "brewnet_db";
|
|
98
|
+
const dbPassword = opts?.dbPassword ?? randomBytes(32).toString("hex");
|
|
99
|
+
const mysqlPassword = randomBytes(32).toString("hex");
|
|
100
|
+
const mysqlRoot = randomBytes(32).toString("hex");
|
|
101
|
+
content = content.replace(/^DB_DRIVER=.*/m, `DB_DRIVER=${dbDriver}`).replace(/^DB_USER=.*/m, `DB_USER=${dbUser}`).replace(/^DB_NAME=.*/m, `DB_NAME=${dbName}`).replace(/^DB_PASSWORD=.*/m, `DB_PASSWORD=${dbPassword}`).replace(/^MYSQL_USER=.*/m, `MYSQL_USER=${dbUser}`).replace(/^MYSQL_DATABASE=.*/m, `MYSQL_DATABASE=${dbName}`).replace(/^MYSQL_PASSWORD=.*/m, `MYSQL_PASSWORD=${mysqlPassword}`).replace(/^MYSQL_ROOT_PASSWORD=.*/m, `MYSQL_ROOT_PASSWORD=${mysqlRoot}`);
|
|
102
|
+
if (opts?.hostPort !== void 0) {
|
|
103
|
+
if (/^BACKEND_PORT=/m.test(content)) {
|
|
104
|
+
content = content.replace(/^BACKEND_PORT=.*/m, `BACKEND_PORT=${opts.hostPort}`);
|
|
105
|
+
} else {
|
|
106
|
+
content += `
|
|
107
|
+
BACKEND_PORT=${opts.hostPort}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (opts?.frontendPort !== void 0) {
|
|
112
|
+
if (/^FRONTEND_PORT=/m.test(content)) {
|
|
113
|
+
content = content.replace(/^FRONTEND_PORT=.*/m, `FRONTEND_PORT=${opts.frontendPort}`);
|
|
114
|
+
} else {
|
|
115
|
+
content += `
|
|
116
|
+
FRONTEND_PORT=${opts.frontendPort}
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (stackId.startsWith("nodejs-")) {
|
|
121
|
+
const provider = PRISMA_PROVIDER[dbDriver] ?? "sqlite";
|
|
122
|
+
const databaseUrl = buildPrismaDatabaseUrl(dbDriver, dbUser, dbPassword, dbName);
|
|
123
|
+
content = content.replace(/^PRISMA_DB_PROVIDER=.*/m, `PRISMA_DB_PROVIDER=${provider}`).replace(/^DATABASE_URL=.*/m, `DATABASE_URL=${databaseUrl}`);
|
|
124
|
+
}
|
|
125
|
+
writeFileSync(envPath, content, "utf-8");
|
|
126
|
+
}
|
|
127
|
+
function patchNextConfig(projectDir, appName) {
|
|
128
|
+
const candidates = ["next.config.ts", "next.config.mjs", "next.config.js"];
|
|
129
|
+
let configPath = null;
|
|
130
|
+
for (const c of candidates) {
|
|
131
|
+
const p = join(projectDir, c);
|
|
132
|
+
if (existsSync(p)) {
|
|
133
|
+
configPath = p;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!configPath) return;
|
|
138
|
+
let content = readFileSync(configPath, "utf-8");
|
|
139
|
+
const basePath = `/apps/${appName}`;
|
|
140
|
+
if (content.includes("basePath")) return;
|
|
141
|
+
content = content.replace(
|
|
142
|
+
/output:\s*['"]standalone['"]/,
|
|
143
|
+
`output: 'standalone',
|
|
144
|
+
basePath: '${basePath}',
|
|
145
|
+
images: { unoptimized: true }`
|
|
146
|
+
);
|
|
147
|
+
if (!content.includes("basePath")) {
|
|
148
|
+
content = content.replace(
|
|
149
|
+
/((?:const|let|var)\s+\w+(?:\s*:\s*[\w<>, |&]+)?\s*=\s*\{|module\.exports\s*=\s*\{|export\s+default\s+\{)/,
|
|
150
|
+
`$1
|
|
151
|
+
basePath: '${basePath}',`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
writeFileSync(configPath, content, "utf-8");
|
|
155
|
+
patchImagePaths(projectDir, `${basePath}/brewnet-site-banner.png`, "/brewnet-site-banner.png");
|
|
156
|
+
const composePath = join(projectDir, "docker-compose.yml");
|
|
157
|
+
if (existsSync(composePath)) {
|
|
158
|
+
let compose = readFileSync(composePath, "utf-8");
|
|
159
|
+
const oldHealthPath = "http://127.0.0.1:3000/health";
|
|
160
|
+
const newHealthPath = `http://127.0.0.1:3000${basePath}/health`;
|
|
161
|
+
if (compose.includes(oldHealthPath) && !compose.includes(newHealthPath)) {
|
|
162
|
+
compose = compose.replaceAll(oldHealthPath, newHealthPath);
|
|
163
|
+
}
|
|
164
|
+
const oldRootPath = "http://127.0.0.1:3000/";
|
|
165
|
+
const newRootPath = `http://127.0.0.1:3000${basePath}/`;
|
|
166
|
+
if (compose.includes(oldRootPath) && !compose.includes(newRootPath)) {
|
|
167
|
+
compose = compose.replaceAll(oldRootPath, newRootPath);
|
|
168
|
+
}
|
|
169
|
+
writeFileSync(composePath, compose, "utf-8");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function parseContainerPort(portSpec) {
|
|
173
|
+
const str = String(portSpec);
|
|
174
|
+
const colonIdx = str.lastIndexOf(":");
|
|
175
|
+
if (colonIdx >= 0) {
|
|
176
|
+
const containerPart = str.slice(colonIdx + 1).replace(/\/.*$/, "");
|
|
177
|
+
const n2 = parseInt(containerPart, 10);
|
|
178
|
+
return isNaN(n2) ? null : n2;
|
|
179
|
+
}
|
|
180
|
+
const n = parseInt(str, 10);
|
|
181
|
+
return isNaN(n) ? null : n;
|
|
182
|
+
}
|
|
183
|
+
function injectTraefikForQuickTunnel(projectDir, appName, _backendPort) {
|
|
184
|
+
const composePath = join(projectDir, "docker-compose.yml");
|
|
185
|
+
if (!existsSync(composePath)) return;
|
|
186
|
+
const raw = readFileSync(composePath, "utf-8");
|
|
187
|
+
const doc = yaml.load(raw);
|
|
188
|
+
const services = doc["services"];
|
|
189
|
+
if (!services) return;
|
|
190
|
+
const isNextjs = ["next.config.ts", "next.config.mjs", "next.config.js"].some((f) => existsSync(join(projectDir, f)));
|
|
191
|
+
if (isNextjs) {
|
|
192
|
+
patchNextConfig(projectDir, appName);
|
|
193
|
+
}
|
|
194
|
+
const skipServices = /* @__PURE__ */ new Set(["postgres", "postgresql", "mysql", "mariadb", "redis", "db"]);
|
|
195
|
+
const backendNames = ["backend", "app", "web", "api", "server"];
|
|
196
|
+
const backendKey = backendNames.find((n) => services[n] && !skipServices.has(n));
|
|
197
|
+
const frontendNames = ["frontend", "ui"];
|
|
198
|
+
const frontendKey = frontendNames.find((n) => services[n]);
|
|
199
|
+
const allSvcKeys = Object.keys(services).filter((k) => !skipServices.has(k));
|
|
200
|
+
const singleService = allSvcKeys.length === 1 ? allSvcKeys[0] : null;
|
|
201
|
+
if (singleService && !backendKey && !frontendKey) {
|
|
202
|
+
const svc = services[singleService];
|
|
203
|
+
const ports = svc["ports"] ?? [];
|
|
204
|
+
const containerPort = ports.length > 0 ? parseContainerPort(ports[0]) ?? 8080 : 8080;
|
|
205
|
+
addQuickTunnelAppLabels(composePath, appName, singleService, containerPort, isNextjs);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (backendKey) {
|
|
209
|
+
const svc = services[backendKey];
|
|
210
|
+
const ports = svc["ports"] ?? [];
|
|
211
|
+
const containerPort = ports.length > 0 ? parseContainerPort(ports[0]) ?? 8080 : 8080;
|
|
212
|
+
addQuickTunnelAppLabels(composePath, appName, backendKey, containerPort, isNextjs);
|
|
213
|
+
}
|
|
214
|
+
if (frontendKey) {
|
|
215
|
+
const svc = services[frontendKey];
|
|
216
|
+
const ports = svc["ports"] ?? [];
|
|
217
|
+
const containerPort = ports.length > 0 ? parseContainerPort(ports[0]) ?? 80 : 80;
|
|
218
|
+
addQuickTunnelAppLabels(composePath, `${appName}-ui`, frontendKey, containerPort);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function startContainers(projectDir) {
|
|
222
|
+
await execa("docker", ["compose", "up", "-d", "--build"], {
|
|
223
|
+
cwd: projectDir
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async function pollHealth(baseUrl, timeoutMs) {
|
|
227
|
+
const start = Date.now();
|
|
228
|
+
const deadline = start + timeoutMs;
|
|
229
|
+
while (Date.now() < deadline) {
|
|
230
|
+
try {
|
|
231
|
+
const res = await fetch(`${baseUrl}/health`, {
|
|
232
|
+
signal: AbortSignal.timeout(3e3)
|
|
233
|
+
});
|
|
234
|
+
if (res.ok) {
|
|
235
|
+
const body = await res.json();
|
|
236
|
+
if (body.status === "ok") {
|
|
237
|
+
return {
|
|
238
|
+
healthy: true,
|
|
239
|
+
elapsedMs: Date.now() - start,
|
|
240
|
+
dbConnected: body.db_connected
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
healthy: false,
|
|
250
|
+
elapsedMs: Date.now() - start,
|
|
251
|
+
error: `Health check timed out after ${Math.round(timeoutMs / 1e3)}s`
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function verifyEndpoints(baseUrl) {
|
|
255
|
+
const helloRes = await fetch(`${baseUrl}/api/hello`, {
|
|
256
|
+
signal: AbortSignal.timeout(5e3)
|
|
257
|
+
});
|
|
258
|
+
if (!helloRes.ok) {
|
|
259
|
+
throw new Error(`GET /api/hello returned HTTP ${helloRes.status}`);
|
|
260
|
+
}
|
|
261
|
+
const helloBody = await helloRes.json();
|
|
262
|
+
if (!helloBody.message) {
|
|
263
|
+
throw new Error(`GET /api/hello response missing "message" field`);
|
|
264
|
+
}
|
|
265
|
+
const echoRes = await fetch(`${baseUrl}/api/echo`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "Content-Type": "application/json" },
|
|
268
|
+
body: JSON.stringify({ test: "brewnet" }),
|
|
269
|
+
signal: AbortSignal.timeout(5e3)
|
|
270
|
+
});
|
|
271
|
+
if (!echoRes.ok) {
|
|
272
|
+
throw new Error(`POST /api/echo returned HTTP ${echoRes.status}`);
|
|
273
|
+
}
|
|
274
|
+
const echoBody = await echoRes.json();
|
|
275
|
+
if (echoBody.test !== "brewnet") {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`POST /api/echo response mismatch: expected test="brewnet", got "${String(echoBody.test)}"`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export {
|
|
283
|
+
findFreePort,
|
|
284
|
+
cloneStack,
|
|
285
|
+
reinitGit,
|
|
286
|
+
generateEnv,
|
|
287
|
+
patchNextConfig,
|
|
288
|
+
injectTraefikForQuickTunnel,
|
|
289
|
+
startContainers,
|
|
290
|
+
pollHealth,
|
|
291
|
+
verifyEndpoints
|
|
292
|
+
};
|
|
293
|
+
//# sourceMappingURL=chunk-DH2VK3YI.js.map
|