// App-Vorlagen im Portainer-Format (v2/v3), z. B. https://github.com/Lissy93/portainer-templates. // Container-Vorlagen (type 1) werden in eine compose.yaml umgewandelt; Stack-Vorlagen (type 2/3) laden die // Compose-Datei aus dem angegebenen GitHub-Repository. Umgebungsvariablen landen mit Standardwerten in der .env. const DEFAULT_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json'; let cache = { url: '', at: 0, list: [] }; const y = (v) => JSON.stringify(String(v)); // JSON-Strings sind gültiges YAML const slug = (s) => String(s || 'app').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40) || 'app'; async function list(url = DEFAULT_URL) { url = url || DEFAULT_URL; if (cache.url === url && Date.now() - cache.at < 3600e3) return cache.list; const res = await fetch(url, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const j = await res.json(); const items = (Array.isArray(j) ? j : j.templates || []).filter((t) => t && t.title && (t.image || t.repository?.stackfile)); const listed = items.map((t, i) => ({ i, title: t.title, description: String(t.description || '').slice(0, 400), categories: t.categories || [], kind: t.image ? 'container' : 'stack', note: t.note || '', t, })); cache = { url, at: Date.now(), list: listed }; return listed; } // Für die Oberfläche ohne Rohdaten const summary = (l) => l.map(({ t: _t, ...rest }) => rest); function envFile(env) { return (env || []).filter((e) => e?.name).map((e) => { const def = e.select ? (e.select.find((x) => x.default) || e.select[0] || {}).value : e.default; const hint = [e.label && e.label !== e.name ? e.label : '', e.description || '', e.select ? `(${e.select.map((x) => x.value).join(' | ')})` : ''].filter(Boolean).join(' – '); return `${hint ? `# ${hint.replace(/\n/g, ' ')}\n` : ''}${e.name}=${def ?? ''}`; }).join('\n'); } function containerCompose(t, name) { const L = ['services:', ` ${name}:`, ` image: ${y(t.image)}`, ` container_name: ${y(name)}`, ` restart: ${y(t.restart_policy || 'unless-stopped')}`]; if (t.hostname) L.push(` hostname: ${y(t.hostname)}`); if (t.command) L.push(` command: ${y(t.command)}`); if (t.privileged) L.push(' privileged: true'); if (t.interactive) L.push(' stdin_open: true', ' tty: true'); if (t.network === 'host') L.push(' network_mode: host'); if (t.ports?.length && t.network !== 'host') L.push(' ports:', ...t.ports.map((p) => ` - ${y(p)}`)); if (t.env?.length) L.push(' environment:', ...t.env.filter((e) => e?.name).map((e) => ` - ${y(`${e.name}=\${${e.name}}`)}`)); if (t.volumes?.length) { L.push(' volumes:', ...t.volumes.filter((v) => v?.container).map((v) => { const host = v.bind ? v.bind.replace(/^\/portainer\/Files\/AppData\/[^/]+\/?/, './data/') : `./data${v.container}`; return ` - ${y(`${host.replace(/\/$/, '') || './data'}:${v.container}${v.readonly ? ':ro' : ''}`)}`; })); } if (t.labels?.length) L.push(' labels:', ...t.labels.filter((l) => l?.name).map((l) => ` ${y(l.name)}: ${y(l.value ?? '')}`)); return L.join('\n') + '\n'; } // Liefert { name, compose, env, note } async function build(index, url) { const l = await list(url); const item = l.find((x) => x.i === index); if (!item) throw new Error('Template not found'); const t = item.t; const name = slug(t.name || t.title); let compose; if (t.image) compose = containerCompose(t, name); else { const m = /^https:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(t.repository.url || ''); if (!m) throw new Error(`Only GitHub repositories are supported (${t.repository.url}). Use “Clone from Git” instead.`); const res = await fetch(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${t.repository.stackfile.replace(/^\/+/, '')}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${t.repository.stackfile}`); compose = await res.text(); } return { name, compose, env: envFile(t.env), note: String(t.note || '').replace(/<[^>]+>/g, '') }; } module.exports = { list: async (url) => summary(await list(url)), build, DEFAULT_URL };