Workflow: add push trigger for main branch on json/*.json and update the "Get JSON file for script" step to handle both workflow_dispatch and push events. The step now collects changed json/*.json files, validates each has a .slug with jq, ignores metadata/update-apps.json/versions.json, writes changed_app_jsons.txt, and sets a count output for downstream steps.
Installer & ct scripts: normalize indentation/formatting, ensure aliases.json and plugin-settings.json are initialized as {} and repos.json as [] when missing, and add missing trailing newlines. These changes improve robustness and shellcheck friendliness and make the workflow respond to direct pushes of app JSON files.
360 lines
19 KiB
YAML
Generated
360 lines
19 KiB
YAML
Generated
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
|