name: Push JSON changes to PocketBase on: push: branches: - main paths: - "json/*.json" workflow_dispatch: inputs: script_slug: description: "Script slug (e.g. my-app)" required: true type: string jobs: push-json: runs-on: self-hosted steps: - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get JSON file for script id: changed run: | : > changed_app_jsons.txt if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then script_slug="${{ github.event.inputs.script_slug }}" file="json/${script_slug}.json" if [[ ! -f "$file" ]]; then echo "No JSON file at $file." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi if ! jq -e '.slug' "$file" >/dev/null 2>&1; then echo "File $file has no .slug." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi echo "$file" > changed_app_jsons.txt echo "count=1" >> "$GITHUB_OUTPUT" exit 0 fi changed=$(git diff --name-only "${{ github.event.before }}" "${{ github.event.after }}" -- json/*.json || true) if [[ -z "$changed" ]]; then echo "No JSON files changed under json/*.json." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi count=0 for file in $changed; do [[ -f "$file" ]] || continue if [[ "$file" == "json/metadata.json" || "$file" == "json/update-apps.json" || "$file" == "json/versions.json" ]]; then continue fi if jq -e '.slug' "$file" >/dev/null 2>&1; then echo "$file" >> changed_app_jsons.txt count=$((count + 1)) fi done if [[ $count -eq 0 ]]; then echo "No app JSON files with .slug found in this push." echo "count=0" >> "$GITHUB_OUTPUT" exit 0 fi echo "count=$count" >> "$GITHUB_OUTPUT" - name: Push to PocketBase if: steps.changed.outputs.count != '0' env: POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }} POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }} POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} run: | node << 'ENDSCRIPT' (async function() { const fs = require('fs'); const https = require('https'); const http = require('http'); const url = require('url'); function request(fullUrl, opts, redirectCount) { redirectCount = redirectCount || 0; return new Promise(function(resolve, reject) { const u = url.parse(fullUrl); const isHttps = u.protocol === 'https:'; const body = opts.body; const options = { hostname: u.hostname, port: u.port || (isHttps ? 443 : 80), path: u.path, method: opts.method || 'GET', headers: opts.headers || {} }; if (body) options.headers['Content-Length'] = Buffer.byteLength(body); const lib = isHttps ? https : http; const req = lib.request(options, function(res) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (redirectCount >= 5) return reject(new Error('Too many redirects from ' + fullUrl)); const redirectUrl = url.resolve(fullUrl, res.headers.location); res.resume(); resolve(request(redirectUrl, opts, redirectCount + 1)); return; } let data = ''; res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data }); }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } const raw = process.env.POCKETBASE_URL.replace(/\/$/, ''); const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api'; const coll = process.env.POCKETBASE_COLLECTION; const files = fs.readFileSync('changed_app_jsons.txt', 'utf8').trim().split(/\s+/).filter(Boolean); const authUrl = apiBase + '/collections/users/auth-with-password'; console.log('Auth URL: ' + authUrl); const authBody = JSON.stringify({ identity: process.env.POCKETBASE_ADMIN_EMAIL, password: process.env.POCKETBASE_ADMIN_PASSWORD }); const authRes = await request(authUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: authBody }); if (!authRes.ok) { throw new Error('Auth failed. Tried: ' + authUrl + ' - Verify POST to that URL with body {"identity":"...","password":"..."} works. Response: ' + authRes.body); } const token = JSON.parse(authRes.body).token; const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records'; let categoryIdToName = {}; try { const metadata = JSON.parse(fs.readFileSync('json/metadata.json', 'utf8')); (metadata.categories || []).forEach(function(cat) { categoryIdToName[cat.id] = cat.name; }); } catch (e) { console.warn('Could not load metadata.json:', e.message); } let typeValueToId = {}; let categoryNameToPbId = {}; try { const typesRes = await request(apiBase + '/collections/z_ref_script_types/records?perPage=500', { headers: { 'Authorization': token } }); if (typesRes.ok) { const typesData = JSON.parse(typesRes.body); (typesData.items || []).forEach(function(item) { if (item.type != null) typeValueToId[item.type] = item.id; if (item.name != null) typeValueToId[item.name] = item.id; if (item.value != null) typeValueToId[item.value] = item.id; }); } } catch (e) { console.warn('Could not fetch z_ref_script_types:', e.message); } try { const catRes = await request(apiBase + '/collections/script_categories/records?perPage=500', { headers: { 'Authorization': token } }); if (catRes.ok) { const catData = JSON.parse(catRes.body); (catData.items || []).forEach(function(item) { if (item.name) categoryNameToPbId[item.name] = item.id; }); } } catch (e) { console.warn('Could not fetch script_categories:', e.message); } var noteTypeToId = {}; var installMethodTypeToId = {}; var osToId = {}; var osVersionToId = {}; try { const res = await request(apiBase + '/collections/z_ref_note_types/records?perPage=500', { headers: { 'Authorization': token } }); if (res.ok) JSON.parse(res.body).items?.forEach(function(item) { if (item.type != null) { noteTypeToId[item.type] = item.id; noteTypeToId[item.type.toLowerCase()] = item.id; } }); } catch (e) { console.warn('z_ref_note_types:', e.message); } try { const res = await request(apiBase + '/collections/z_ref_install_method_types/records?perPage=500', { headers: { 'Authorization': token } }); if (res.ok) JSON.parse(res.body).items?.forEach(function(item) { if (item.type != null) { installMethodTypeToId[item.type] = item.id; installMethodTypeToId[item.type.toLowerCase()] = item.id; } }); } catch (e) { console.warn('z_ref_install_method_types:', e.message); } try { const res = await request(apiBase + '/collections/z_ref_os/records?perPage=500', { headers: { 'Authorization': token } }); if (res.ok) JSON.parse(res.body).items?.forEach(function(item) { if (item.os != null) { osToId[item.os] = item.id; osToId[item.os.toLowerCase()] = item.id; } }); } catch (e) { console.warn('z_ref_os:', e.message); } try { const res = await request(apiBase + '/collections/z_ref_os_version/records?perPage=500&expand=os', { headers: { 'Authorization': token } }); if (res.ok) { (JSON.parse(res.body).items || []).forEach(function(item) { var osName = item.expand && item.expand.os && item.expand.os.os != null ? item.expand.os.os : null; if (osName != null && item.version != null) { var key = osName + '|' + item.version; osVersionToId[key] = item.id; osVersionToId[osName.toLowerCase() + '|' + item.version] = item.id; } }); } } catch (e) { console.warn('z_ref_os_version:', e.message); } var notesCollUrl = apiBase + '/collections/script_notes/records'; var installMethodsCollUrl = apiBase + '/collections/script_install_methods/records'; for (const file of files) { if (!fs.existsSync(file)) continue; const data = JSON.parse(fs.readFileSync(file, 'utf8')); if (!data.slug) { console.log('Skipping', file, '(no slug)'); continue; } // execute_in: map type to canonical value var executeInMap = { ct: 'lxc', lxc: 'lxc', turnkey: 'turnkey', pve: 'pve', addon: 'addon', vm: 'vm' }; var executeIn = data.type ? (executeInMap[data.type.toLowerCase()] || data.type.toLowerCase()) : null; // github: extract owner/repo from full GitHub URL var githubField = null; var projectUrl = data.github || null; if (data.github) { var ghMatch = data.github.match(/github\.com\/([^/]+\/[^/?#]+)/); if (ghMatch) githubField = ghMatch[1].replace(/\.git$/, ''); } // last_update_commit: last commit touching the actual script files (ct/slug.sh, install/slug-install.sh, vm/slug.sh, etc.) var lastCommit = null; try { var cp = require('child_process'); var scriptFiles = []; // primary script from install_methods[].script (e.g. "ct/teleport.sh", "vm/teleport.sh") (data.install_methods || []).forEach(function(im) { if (im.script) scriptFiles.push(im.script); }); // derive install script from slug (install/slug-install.sh) scriptFiles.push('install/' + data.slug + '-install.sh'); // filter to only files that actually exist in git var existingFiles = scriptFiles.filter(function(f) { try { cp.execSync('git ls-files --error-unmatch ' + f, { stdio: 'ignore' }); return true; } catch(e) { return false; } }); if (existingFiles.length > 0) { lastCommit = cp.execSync('git log -1 --format=%H -- ' + existingFiles.join(' ')).toString().trim() || null; } } catch(e) { console.warn('Could not get last commit:', e.message); } var payload = { name: data.name, slug: data.slug, script_created: data.date_created || data.script_created, script_updated: new Date().toISOString().split('T')[0], updateable: data.updateable, privileged: data.privileged, port: data.interface_port != null ? data.interface_port : data.port, documentation: data.documentation, website: data.website, logo: data.logo, description: data.description, config_path: data.config_path, default_user: (data.default_credentials && data.default_credentials.username) || data.default_user || null, default_passwd: (data.default_credentials && data.default_credentials.password) || data.default_passwd || null, notes_json: JSON.stringify(data.notes || []), install_methods_json: JSON.stringify(data.install_methods || []), is_dev: true }; if (executeIn) payload.execute_in = executeIn; if (githubField) payload.github = githubField; if (projectUrl) payload.project_url = projectUrl; if (lastCommit) payload.last_update_commit = lastCommit; var resolvedType = typeValueToId[data.type]; if (resolvedType == null && data.type === 'ct') resolvedType = typeValueToId['lxc']; if (resolvedType) payload.type = resolvedType; var resolvedCats = (data.categories || []).map(function(n) { return categoryNameToPbId[categoryIdToName[n]]; }).filter(Boolean); if (resolvedCats.length) payload.categories = resolvedCats; if (data.version !== undefined) payload.version = data.version; if (data.changelog !== undefined) payload.changelog = data.changelog; if (data.screenshots !== undefined) payload.screenshots = data.screenshots; const filter = "(slug='" + data.slug + "')"; const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', { headers: { 'Authorization': token } }); const list = JSON.parse(listRes.body); const existingId = list.items && list.items[0] && list.items[0].id; async function resolveNotesAndInstallMethods(scriptId) { var noteIds = []; for (var i = 0; i < (data.notes || []).length; i++) { var note = data.notes[i]; var typeId = noteTypeToId[note.type] || (note.type && noteTypeToId[note.type.toLowerCase()]); if (typeId == null) { console.warn('Note type not in z_ref_note_types:', note.type); continue; } var postRes = await request(notesCollUrl, { method: 'POST', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: note.text || '', type: typeId, script: scriptId }) }); if (postRes.ok) noteIds.push(JSON.parse(postRes.body).id); else console.error('script_notes POST failed:', postRes.statusCode, postRes.body); } var installMethodIds = []; for (var j = 0; j < (data.install_methods || []).length; j++) { var im = data.install_methods[j]; var typeId = installMethodTypeToId[im.type] || (im.type && installMethodTypeToId[im.type.toLowerCase()]); var res = im.resources || {}; var osId = osToId[res.os] || (res.os && osToId[res.os.toLowerCase()]); var osVersionKey = (res.os != null && res.version != null) ? res.os + '|' + res.version : null; var osVersionId = osVersionKey ? (osVersionToId[osVersionKey] || osVersionToId[res.os.toLowerCase() + '|' + res.version]) : null; var imBody = { script: scriptId, resources_cpu: res.cpu != null ? res.cpu : 0, resources_ram: res.ram != null ? res.ram : 0, resources_hdd: res.hdd != null ? res.hdd : 0 }; if (typeId) imBody.type = typeId; if (osId) imBody.os = osId; if (osVersionId) imBody.os_version = osVersionId; var imPostRes = await request(installMethodsCollUrl, { method: 'POST', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify(imBody) }); if (imPostRes.ok) installMethodIds.push(JSON.parse(imPostRes.body).id); else console.error('script_install_methods POST failed:', imPostRes.statusCode, imPostRes.body); } return { noteIds: noteIds, installMethodIds: installMethodIds }; } if (existingId) { var resolved = await resolveNotesAndInstallMethods(existingId); payload.notes = resolved.noteIds; payload.install_methods = resolved.installMethodIds; console.log('Updating', file, '(slug=' + data.slug + ')'); console.log('Created', resolved.noteIds.length, 'notes,', resolved.installMethodIds.length, 'install_methods for slug=' + data.slug); const r = await request(recordsUrl + '/' + existingId, { method: 'PATCH', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('PATCH failed: ' + r.body); } else { console.log('Creating', file, '(slug=' + data.slug + ')'); const r = await request(recordsUrl, { method: 'POST', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('POST failed: ' + r.body); var scriptId = JSON.parse(r.body).id; var resolved = await resolveNotesAndInstallMethods(scriptId); console.log('Created', resolved.noteIds.length, 'notes,', resolved.installMethodIds.length, 'install_methods for slug=' + data.slug); var patchPayload = {}; for (var k in payload) patchPayload[k] = payload[k]; patchPayload.notes = resolved.noteIds; patchPayload.install_methods = resolved.installMethodIds; var patchRes = await request(recordsUrl + '/' + scriptId, { method: 'PATCH', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify(patchPayload) }); if (!patchRes.ok) throw new Error('PATCH relations failed: ' + patchRes.body); var patched = JSON.parse(patchRes.body); console.log('Script linked: notes=' + (patched.notes && patched.notes.length) + ', install_methods=' + (patched.install_methods && patched.install_methods.length)); } } console.log('Done.'); })().catch(e => { console.error(e); process.exit(1); }); ENDSCRIPT shell: bash