{ "id": "lozEf1Wq3012xbAk", "name": "NPM to Homepage (SSH)", "nodes": [ { "parameters": { "jsCode": "// Extract the token from Supabase response\nconst tokenRecords = items;\n\nif (!tokenRecords || tokenRecords.length === 0 || !tokenRecords[0].json.token) {\n throw new Error('❌ NPM token not found in Supabase. Please run the NPM Auth Token Manager workflow first.');\n}\n\nconst tokenRecord = tokenRecords[0].json;\n\nconsole.log('✅ Loaded NPM token from Supabase');\nconsole.log('Token expires at:', tokenRecord.expires_at);\nconsole.log('Last updated:', tokenRecord.last_updated);\n\nreturn [{\n json: {\n token: tokenRecord.token,\n expiresAt: tokenRecord.expires_at\n }\n}];" }, "id": "401c1f93-a150-4dc3-aa2d-da007edacb8f", "name": "Extract Token Value", "position": [ 496, 32 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Extracts the token value and validates it exists." }, { "parameters": { "url": "https://npm.dfw.ben.io/api/nginx/proxy-hosts", "options": {}, "headerParametersUi": { "parameter": [ { "name": "Authorization", "value": "=Bearer {{ $json.token }}" } ] } }, "id": "85e32a06-5c0e-409a-95d9-5eec7d4c71f3", "name": "Get NPM Hosts", "position": [ 688, 32 ], "type": "n8n-nodes-base.httpRequest", "typeVersion": 1, "notes": "Fetches the list of all configured proxy hosts from NPM using the stored long-lived token." }, { "parameters": {}, "id": "796fba9f-a65d-49d4-bdda-4f09ac7ad926", "name": "Merge Data Sources", "position": [ 880, 48 ], "type": "n8n-nodes-base.merge", "typeVersion": 3, "notes": "Merges NPM hosts and Supabase mappings into a single execution path to prevent duplicate processing." }, { "parameters": { "jsCode": "// Process merged data: NPM hosts array and Supabase mappings\n// Input 1: NPM hosts (array)\n// Input 2: Supabase mappings (records)\n\nlet hosts = [];\nlet mappings = {};\n\n// Process all incoming items to extract hosts and mappings\nfor (const item of items) {\n const data = item.json;\n \n // Check if this is NPM hosts data (array at root)\n if (Array.isArray(data)) {\n hosts = data;\n console.log('Received NPM hosts:', hosts.length);\n }\n // Check if this is Supabase data (has service_name and icon_reference)\n else if (data.service_name && data.icon_reference) {\n mappings[data.service_name.toLowerCase()] = data.icon_reference;\n }\n}\n\nconsole.log('=== Supabase Mappings Loaded ===');\nconsole.log('Total mappings from Supabase:', Object.keys(mappings).length);\nfor (const [key, value] of Object.entries(mappings).slice(0, 5)) {\n console.log(` ${key} -> ${value}`);\n}\nif (Object.keys(mappings).length > 5) {\n console.log(` ... and ${Object.keys(mappings).length - 5} more`);\n}\n\nconsole.log('NPM hosts retrieved:', hosts.length);\n\nreturn [{ json: { databaseMappings: mappings, unmappedDefault: 'streamlink', npmHosts: hosts } }];" }, "id": "e04e3082-2173-4615-b9f2-cd6aa0ddefcd", "name": "Cache Mappings in Memory", "position": [ 1088, 48 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Processes merged data from both sources (NPM hosts and Supabase mappings). Combines them into a single output for downstream processing." }, { "parameters": { "jsCode": "// Process NPM hosts with Supabase mappings\n// This node receives a single input from Cache Mappings in Memory\n// which contains both databaseMappings and npmHosts\n\nconst inputData = items[0].json;\nconst hosts = inputData.npmHosts || [];\nconst databaseMappings = inputData.databaseMappings || {};\n\nconsole.log('=== Process ALL Hosts (Supabase Mappings) ===');\nconsole.log('NPM hosts received:', hosts.length);\nconsole.log('Database mappings loaded:', Object.keys(databaseMappings).length);\n\nif (!Array.isArray(hosts) || hosts.length === 0) {\n console.log('No hosts to process');\n return [{ json: { hosts: [], staticMappings: databaseMappings } }];\n}\n\n// Filter enabled hosts and extract service names\nconst enabledHosts = hosts.filter(host => host && host.enabled && host.domain_names && host.domain_names.length > 0);\nconst serviceNames = enabledHosts.map(host => host.domain_names[0].split('.')[0]);\n\nconsole.log('Enabled hosts:', enabledHosts.length);\nconsole.log('Found services:', serviceNames.length);\nconsole.log('Services:', serviceNames.slice(0, 10).join(', ') + (serviceNames.length > 10 ? '...' : ''));\n\nreturn [{ json: { hosts: enabledHosts, staticMappings: databaseMappings } }];" }, "id": "71e9143a-8ea4-4f98-9149-be75edf02731", "name": "Process ALL Hosts", "position": [ 880, 256 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Receives NPM hosts array directly. Uses mappings from Supabase database. Pure dynamic lookup - no hardcoded values." }, { "parameters": { "jsCode": "const item_json = items.length > 0 && items[0].json ? items[0].json : {};\nconst hosts = item_json.hosts || [];\nconst staticMappings = item_json.staticMappings || {};\nconst unmappedDefault = $node['Cache Mappings in Memory'].json.unmappedDefault || 'streamlink';\n\nconsole.log('=== Create Services from Supabase Mappings ===');\nconsole.log('Unmapped default:', unmappedDefault);\n\nconst allNewServices = [];\nif (hosts && hosts.length > 0) {\n for (const host of hosts) {\n if (!host?.enabled || !host?.domain_names?.length) continue;\n const domain = host.domain_names[0];\n const serviceName = domain.split('.')[0];\n const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1);\n const protocol = host.ssl_forced ? 'https' : 'http';\n const url = `${protocol}://${domain}`;\n \n // Check Supabase mappings (ground truth)\n let iconReference = staticMappings[serviceName.toLowerCase()];\n \n // Fallback to unmapped default if not found in Supabase\n if (!iconReference) {\n iconReference = unmappedDefault;\n }\n \n const iconName = `sh-${iconReference}`;\n const serviceObject = {};\n serviceObject[capitalizedServiceName] = { icon: iconName, href: url, ping: url, statusStyle: 'dot' };\n allNewServices.push(serviceObject);\n \n console.log(`${serviceName} -> ${iconReference}`);\n }\n}\n\nconsole.log('Total services created:', allNewServices.length);\nreturn [{ json: { newServices: allNewServices } }];" }, "id": "7c1703e7-6bec-48e5-a433-d6fce1dd4009", "name": "Create Homepage Services", "position": [ 1120, 256 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Creates Homepage service objects using mappings from Supabase database. Uses 'streamlink' as unmapped default for any services not in database." }, { "parameters": { "values": { "string": [ { "name": "homepageServicesPath", "value": "/opt/homepage/config/services.yaml" } ] }, "options": {} }, "id": "961f50ee-94d0-4ffd-ad8a-6ff68ff19710", "name": "SSH Config", "type": "n8n-nodes-base.set", "typeVersion": 1, "position": [ 96, 464 ], "notes": "Stores SSH path configuration for Homepage services.yaml" }, { "parameters": { "jsCode": "// Reset pairedItem tracking by returning items without paired metadata\n// This allows the SSH command to access $() references without pairing conflicts\nreturn items.map(item => ({ json: item.json || item }));" }, "id": "f5c635b4-f37e-40b9-a66f-e08f89dc07a4", "name": "Reset Pairing for SSH", "position": [ 288, 464 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Resets pairedItem tracking to prevent errors when accessing variables from earlier in the workflow. Allows SSH node to reference config without pairing conflicts." }, { "parameters": { "authentication": "privateKey", "command": "=cat {{ $node[\"SSH Config\"].json.homepageServicesPath }}" }, "id": "78840546-a019-4ab7-be64-34bfb641847c", "name": "Read services.yaml via SSH", "position": [ 496, 464 ], "type": "n8n-nodes-base.ssh", "typeVersion": 1, "credentials": { "sshPrivateKey": { "id": "jBV4AQlsHxWnFXsp", "name": "homepage.sshkey" } }, "notes": "Reads the current services.yaml file from your remote Homepage server via SSH." }, { "parameters": { "jsCode": "// Enhanced YAML parser for Homepage format - PRESERVES INDENTATION\nconsole.log('=== DEBUG YAML to JSON ===');\n\nconst existingYamlString = $('Read services.yaml via SSH').item.json.stdout;\nconsole.log('Existing YAML length:', existingYamlString?.length || 0);\n\nfunction parseHomepageYaml(yamlString) {\n try {\n const lines = yamlString.split('\\n');\n const result = [];\n let currentGroup = null;\n let currentServices = [];\n let currentService = null;\n \n for (let i = 0; i < lines.length; i++) {\n const fullLine = lines[i];\n const line = fullLine.trim();\n \n if (!line || line.startsWith('#')) continue;\n \n const indent = fullLine.length - fullLine.trimStart().length;\n \n if (indent === 0 && line.match(/^-\\s*([^:]+):$/)) {\n if (currentGroup !== null && currentServices.length > 0) {\n result.push({ [currentGroup]: currentServices });\n }\n currentGroup = line.match(/^-\\s*([^:]+):$/)[1].trim();\n currentServices = [];\n currentService = null;\n console.log('Found group:', currentGroup);\n continue;\n }\n \n if (indent === 4 && line.match(/^-\\s*([^:]+):$/) && currentGroup !== null) {\n const serviceName = line.match(/^-\\s*([^:]+):$/)[1].trim();\n currentService = { [serviceName]: {} };\n currentServices.push(currentService);\n console.log('Found service:', serviceName);\n continue;\n }\n \n if (indent >= 8 && line.includes(':') && currentService !== null) {\n const match = line.match(/^([^:]+):\\s*(.*)$/);\n if (match) {\n const key = match[1].trim();\n let value = match[2].trim();\n \n if (value === 'null' || value === '~') {\n value = null;\n } else if (value === 'true' || value === 'false') {\n value = value === 'true';\n } else if (!isNaN(value) && value !== '') {\n value = Number(value);\n } else if ((value.startsWith('\"') && value.endsWith('\"')) || (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n \n const serviceName = Object.keys(currentService)[0];\n currentService[serviceName][key] = value;\n }\n }\n }\n \n if (currentGroup !== null && currentServices.length > 0) {\n result.push({ [currentGroup]: currentServices });\n }\n \n console.log('Final result groups:', result.length);\n result.forEach(g => {\n const groupName = Object.keys(g)[0];\n console.log(` ${groupName}: ${g[groupName].length} services`);\n });\n \n return result;\n } catch (error) {\n console.error('Error parsing YAML:', error);\n return [];\n }\n}\n\nlet doc;\ntry {\n doc = parseHomepageYaml(existingYamlString);\n console.log('Parsed YAML groups:', doc.length);\n} catch (e) {\n console.error('Error parsing existing YAML', e);\n doc = [];\n}\n\nreturn [{ json: { services: doc } }];" }, "id": "d145ccd1-4804-4143-b849-3a0b46bb5c22", "name": "YAML to JSON", "position": [ 672, 464 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Parses the existing YAML using built-in parser and converts it to a JSON object." }, { "parameters": { "jsCode": "// DE-DUPLICATE Links section - keep only unique services\nconsole.log('=== UPDATE YAML file (De-duplication Fix v2) ===');\n\nconst newServices = $('Create Homepage Services').item.json.newServices;\nconst existingYamlString = $('Read services.yaml via SSH').item.json.stdout;\n\nconst lines = existingYamlString.split('\\n');\nlet output = [];\nlet linksStartIndex = -1;\n\n// Find where Links section starts\nfor (let i = 0; i < lines.length; i++) {\n if (lines[i].trim() === '- Links:') {\n linksStartIndex = i;\n console.log('Found Links section at line:', i);\n break;\n }\n}\n\nif (linksStartIndex === -1) {\n // No Links section - add everything plus new Links section\n output = lines.filter(l => l.trim());\n output.push('');\n output.push('- Links:');\n for (const service of newServices) {\n const serviceName = Object.keys(service)[0];\n const config = service[serviceName];\n output.push(` - ${serviceName}:`);\n for (const [key, value] of Object.entries(config)) {\n const formattedValue = typeof value === 'string' ? `\"${value}\"` : value;\n output.push(` ${key}: ${formattedValue}`);\n }\n }\n} else {\n // Links section exists - replace it entirely with new services\n // Step 1: Add everything BEFORE Links section\n for (let i = 0; i < linksStartIndex; i++) {\n output.push(lines[i]);\n }\n \n // Step 2: Add Links header\n output.push('- Links:');\n \n // Step 3: Add ONLY new NPM services (no duplicates)\n console.log('Adding', newServices.length, 'unique NPM services');\n for (const service of newServices) {\n const serviceName = Object.keys(service)[0];\n const config = service[serviceName];\n output.push(` - ${serviceName}:`);\n for (const [key, value] of Object.entries(config)) {\n const formattedValue = typeof value === 'string' ? `\"${value}\"` : value;\n output.push(` ${key}: ${formattedValue}`);\n }\n }\n \n console.log('Removed all old Links content, added fresh NPM services');\n}\n\n// Remove trailing empty lines and ensure single trailing newline\nwhile (output.length > 0 && !output[output.length - 1].trim()) {\n output.pop();\n}\noutput.push('');\n\nconst newYamlString = output.join('\\n');\nconsole.log('Final YAML length:', newYamlString.length, 'lines:', output.length);\nconsole.log('NPM services added:', newServices.length);\n\nreturn [{ json: { yaml: newYamlString } }];" }, "id": "307414bb-d7c2-4513-a7ca-8c09e9fd1094", "name": "Update YAML file", "position": [ 848, 464 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "REPLACES the entire Links section with fresh NPM hosts (de-duplication fix). Previously preserved old services causing endless growth. Now clears and rebuilds Links section each run." }, { "parameters": { "jsCode": "// Encode YAML as Base64 to safely transfer via SSH\nconst yamlContent = $('Update YAML file').item.json.yaml;\nconsole.log('Original YAML length:', yamlContent.length);\n\n// Convert string to Base64\nconst base64Yaml = Buffer.from(yamlContent).toString('base64');\nconsole.log('Base64 encoded length:', base64Yaml.length);\nconsole.log('Base64 preview:', base64Yaml.substring(0, 50) + '...');\n\nreturn [{ json: { base64Yaml: base64Yaml, originalLength: yamlContent.length } }];" }, "id": "eeba120e-fb90-4a9f-b395-1c06cfb46e11", "name": "Prepare Base64 YAML", "position": [ 1040, 464 ], "type": "n8n-nodes-base.code", "typeVersion": 2, "notes": "Encodes the YAML content as Base64 to safely pass it through SSH without shell interpretation issues." }, { "parameters": { "authentication": "privateKey", "command": "=cp {{ $node[\"SSH Config\"].json.homepageServicesPath }} {{ $node[\"SSH Config\"].json.homepageServicesPath }}.backup.$(date +%Y%m%d_%H%M%S)" }, "id": "769f1bc8-5922-4d5f-993c-e1741e7b3dbf", "name": "Backup Current Config via SSH", "position": [ 1232, 464 ], "type": "n8n-nodes-base.ssh", "typeVersion": 1, "credentials": { "sshPrivateKey": { "id": "jBV4AQlsHxWnFXsp", "name": "homepage.sshkey" } }, "notes": "Creates a timestamped backup of the current services.yaml before writing the new one." }, { "parameters": { "authentication": "privateKey", "command": "=echo {{ $node[\"Prepare Base64 YAML\"].json.base64Yaml }} | base64 -d > {{$node[\"SSH Config\"].json.homepageServicesPath}}\necho 'Write successful' && wc -l {{$node[\"SSH Config\"].json.homepageServicesPath}}" }, "id": "84611366-6e89-4d1c-a36a-9ceaf7cb9068", "name": "Write services.yaml via SSH", "position": [ 1440, 464 ], "type": "n8n-nodes-base.ssh", "typeVersion": 1, "credentials": { "sshPrivateKey": { "id": "jBV4AQlsHxWnFXsp", "name": "homepage.sshkey" } }, "notes": "Decodes Base64 YAML and writes to remote file, safely avoiding shell escaping issues." }, { "parameters": { "rule": { "interval": [ {} ] } }, "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ 96, 32 ], "id": "afe8836d-d01d-435a-a4a1-e208251da69b", "name": "Schedule Trigger" }, { "parameters": { "operation": "executeQuery", "query": "SELECT * FROM npm_tokens WHERE service_name = 'npm_dfw';", "additionalFields": {} }, "id": "96cf0d45-237b-4d33-a50d-fb54fa975761", "name": "Load NPM Token from Postgres", "type": "n8n-nodes-base.postgres", "typeVersion": 1, "position": [ 304, 32 ], "credentials": { "postgres": { "id": "Ik8CFyap8ic2Md3M", "name": "n8n-infra" } } }, { "parameters": { "operation": "executeQuery", "query": "SELECT * FROM service_mappings;", "additionalFields": {} }, "id": "953a7ece-973f-41ef-a9ba-c9d3dffd8f01", "name": "Load Icon Mappings from Postgres", "type": "n8n-nodes-base.postgres", "typeVersion": 1, "position": [ 496, 224 ], "credentials": { "postgres": { "id": "Ik8CFyap8ic2Md3M", "name": "n8n-infra" } } } ], "connections": { "Extract Token Value": { "main": [ [ { "node": "Get NPM Hosts", "type": "main", "index": 0 } ] ] }, "Get NPM Hosts": { "main": [ [ { "node": "Merge Data Sources", "type": "main", "index": 0 }, { "node": "Load Icon Mappings from Postgres", "type": "main", "index": 0 } ] ] }, "Merge Data Sources": { "main": [ [ { "node": "Cache Mappings in Memory", "type": "main", "index": 0 } ] ] }, "Cache Mappings in Memory": { "main": [ [ { "node": "Process ALL Hosts", "type": "main", "index": 0 } ] ] }, "Process ALL Hosts": { "main": [ [ { "node": "Create Homepage Services", "type": "main", "index": 0 } ] ] }, "Create Homepage Services": { "main": [ [ { "node": "Reset Pairing for SSH", "type": "main", "index": 0 }, { "node": "SSH Config", "type": "main", "index": 0 } ] ] }, "SSH Config": { "main": [ [ { "node": "Reset Pairing for SSH", "type": "main", "index": 0 } ] ] }, "Reset Pairing for SSH": { "main": [ [ { "node": "Read services.yaml via SSH", "type": "main", "index": 0 } ] ] }, "Read services.yaml via SSH": { "main": [ [ { "node": "YAML to JSON", "type": "main", "index": 0 } ] ] }, "YAML to JSON": { "main": [ [ { "node": "Update YAML file", "type": "main", "index": 0 } ] ] }, "Update YAML file": { "main": [ [ { "node": "Prepare Base64 YAML", "type": "main", "index": 0 } ] ] }, "Prepare Base64 YAML": { "main": [ [ { "node": "Backup Current Config via SSH", "type": "main", "index": 0 } ] ] }, "Backup Current Config via SSH": { "main": [ [ { "node": "Write services.yaml via SSH", "type": "main", "index": 0 } ] ] }, "Schedule Trigger": { "main": [ [ { "node": "Load NPM Token from Postgres", "type": "main", "index": 0 } ] ] }, "Load NPM Token from Postgres": { "main": [ [ { "node": "Extract Token Value", "type": "main", "index": 0 } ] ] }, "Load Icon Mappings from Postgres": { "main": [ [ { "node": "Merge Data Sources", "type": "main", "index": 1 } ] ] } }, "settings": { "executionOrder": "v1", "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, "triggerCount": 1, "versionId": "0720cdd2-cd1e-4b49-8d1e-25d1dfaefa66", "owner": { "type": "personal", "projectId": "FeLO36wNUAcn61Wj", "projectName": "Ben W ", "personalEmail": "admin@ben.io" }, "parentFolderId": "eWW72giJDI4fxlWw", "isArchived": false }