mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-09-16 14:30:33 -04:00
* pocketbase-bot: accept the var_ names and two missing fields cpu, ram, hdd, os and version were already reachable, but only under the PocketBase names. People type what the ct scripts call them, so "/pocketbase <slug> var_ram=4096" was rejected as an unknown field while "ram=4096" worked. The bot already carried the mapping as RESOURCE_TO_CT_VAR, for display only. Normalise the keys in parseKVPairs, so both the field=value path and the method path accept them, along with disk and memory as the other two names people reach for. Matching is case-insensitive. pin_reason and last_update_commit exist on the record and are worth editing, but were not in ALLOWED_FIELDS. slug, script_created and script_updated stay out: the first is the key the command looks the record up by, the other two belong to the timestamp workflow. notes and install_methods keep their own subcommands. * pocketbase-bot: write the sync PR against the PR template The sync PR body had its own Summary and Source headings, so the autolabeler found none of the template checkboxes it looks for and the PR came out with nothing but "needs triage". It also never referenced the issue the command came from. Write the body the way the template expects, with the Website update box ticked, which is what a PocketBase sync is. Tested thoroughly stays unticked and Tested on says not tested, because nothing here was run; close-invalid-pr-template skips bot authors, so that costs nothing. Reference the triggering number as Fixes when the command came from an issue and as a plain mention when it came from a PR comment, where Fixes would point the PR at itself. issue_comment carries both under github.event.issue, so the new IS_PR_COMMENT tells them apart. * ci: stop the .app header PR being closed as a new script allowedBots carried "community-scripts-pr-app" but not the "[bot]"-suffixed name GitHub actually reports, and the check is an exact match, so the exemption never applied to it. push-app-to-main is listed both ways; this one was not. PR #17304 was closed as an untested new script submission because of it. generate-app-headers.sh empties ct/headers, tools/headers and vm/headers and writes them again, so every run reports those files as added. That is what the autolabeler's new-script rule looks for, and the vm rule matched vm/headers too. Exclude the header directories from both, and skip them in the close workflow's own added-file fallback, so the label cannot come back by another route. Checked against minimatch with the shipped config: header files get neither label, ct/*.sh, install/*.sh and vm/*.sh still get theirs. * pocketbase-bot: label the sync PR as a bugfix The sync corrects CT defaults that no longer match the PocketBase record, so bugfix describes it better than website update, which is meant for metadata changes on the site itself.
1234 lines
64 KiB
YAML
Generated
1234 lines
64 KiB
YAML
Generated
name: PocketBase Bot
|
||
|
||
on:
|
||
issue_comment:
|
||
types: [created]
|
||
|
||
permissions:
|
||
issues: write
|
||
pull-requests: write
|
||
contents: write
|
||
|
||
jobs:
|
||
pocketbase-bot:
|
||
runs-on: self-hosted
|
||
|
||
# Act on comments that contain a /pocketbase command line (precise line check happens in-script)
|
||
if: contains(github.event.comment.body, '/pocketbase')
|
||
|
||
steps:
|
||
- name: Execute PocketBase bot command
|
||
env:
|
||
POCKETBASE_URL: ${{ vars.POCKETBASE_URL }}
|
||
POCKETBASE_COLLECTION: ${{ vars.POCKETBASE_COLLECTION }}
|
||
POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }}
|
||
POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
|
||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||
COMMENT_ID: ${{ github.event.comment.id }}
|
||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||
IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || 'false' }}
|
||
REPO_OWNER: ${{ github.repository_owner }}
|
||
REPO_NAME: ${{ github.event.repository.name }}
|
||
ACTOR: ${{ github.event.comment.user.login }}
|
||
ACTOR_ASSOCIATION: ${{ github.event.comment.author_association }}
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
FRONTEND_URL: ${{ vars.FRONTEND_URL }}
|
||
REVALIDATE_SECRET: ${{ secrets.FRONTEND_INGEST_SECRET }}
|
||
# Screenshot attaching is delegated to the frontend, which owns the
|
||
# fetching and validation. Without this the subcommand says so rather
|
||
# than failing halfway.
|
||
SCREENSHOT_IMPORT_SECRET: ${{ secrets.FRONTEND_INGEST_SECRET }}
|
||
run: |
|
||
node << 'ENDSCRIPT'
|
||
(async function () {
|
||
const https = require('https');
|
||
const http = require('http');
|
||
const url = require('url');
|
||
|
||
// ── HTTP helper with redirect following ────────────────────────────
|
||
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();
|
||
});
|
||
}
|
||
|
||
// ── GitHub API helpers ─────────────────────────────────────────────
|
||
const owner = process.env.REPO_OWNER;
|
||
const repo = process.env.REPO_NAME;
|
||
const issueNumber = parseInt(process.env.ISSUE_NUMBER, 10);
|
||
const isPrComment = process.env.IS_PR_COMMENT === 'true';
|
||
const commentId = parseInt(process.env.COMMENT_ID, 10);
|
||
const actor = process.env.ACTOR;
|
||
|
||
function ghRequest(path, method, body) {
|
||
const headers = {
|
||
'Authorization': 'Bearer ' + process.env.GITHUB_TOKEN,
|
||
'Accept': 'application/vnd.github+json',
|
||
'X-GitHub-Api-Version': '2022-11-28',
|
||
'User-Agent': 'PocketBase-Bot'
|
||
};
|
||
const bodyStr = body ? JSON.stringify(body) : undefined;
|
||
if (bodyStr) headers['Content-Type'] = 'application/json';
|
||
return request('https://api.github.com' + path, { method: method || 'GET', headers, body: bodyStr });
|
||
}
|
||
|
||
function encodeContentPath(filePath) {
|
||
return filePath.split('/').map(encodeURIComponent).join('/');
|
||
}
|
||
|
||
function decodeGitHubContent(content) {
|
||
return Buffer.from((content || '').replace(/\n/g, ''), 'base64').toString('utf8');
|
||
}
|
||
|
||
function sanitizeBranchPart(value) {
|
||
return (value || '')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9._/-]+/g, '-')
|
||
.replace(/\/+/g, '/')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
function applyCtDefaultChanges(scriptText, varChanges) {
|
||
let nextText = scriptText;
|
||
const updatedVars = [];
|
||
const unchangedVars = [];
|
||
for (const [varName, rawValue] of Object.entries(varChanges)) {
|
||
const newValue = String(rawValue);
|
||
const pattern = new RegExp('(^\\s*' + varName + '="\\$\\{' + varName + ':-)([^"}]*)(\\}"\\s*$)', 'm');
|
||
const match = nextText.match(pattern);
|
||
if (!match) continue;
|
||
if (match[2] === newValue) {
|
||
unchangedVars.push(varName);
|
||
continue;
|
||
}
|
||
nextText = nextText.replace(pattern, '$1' + newValue + '$3');
|
||
updatedVars.push(varName);
|
||
}
|
||
return { nextText, updatedVars, unchangedVars };
|
||
}
|
||
|
||
async function ensureBranch(defaultBranch, branchName) {
|
||
const branchRefRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/ref/heads/' + encodeURIComponent(branchName));
|
||
if (branchRefRes.ok) return;
|
||
|
||
const defaultRefRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/ref/heads/' + encodeURIComponent(defaultBranch));
|
||
if (!defaultRefRes.ok) {
|
||
throw new Error('Could not read default branch ref: ' + defaultRefRes.body);
|
||
}
|
||
const defaultRef = JSON.parse(defaultRefRes.body);
|
||
const createBranchRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/refs', 'POST', {
|
||
ref: 'refs/heads/' + branchName,
|
||
sha: defaultRef.object.sha
|
||
});
|
||
if (!createBranchRes.ok) {
|
||
throw new Error('Could not create branch: ' + createBranchRes.body);
|
||
}
|
||
}
|
||
|
||
async function upsertCtDefaultsPr(slugValue, varChanges) {
|
||
const wantedEntries = Object.entries(varChanges || {}).filter(function ([, v]) {
|
||
return v !== undefined && v !== null && String(v) !== '';
|
||
});
|
||
if (wantedEntries.length === 0) {
|
||
return { status: 'skipped', reason: 'No mapped CT defaults changed.' };
|
||
}
|
||
|
||
const repoRes = await ghRequest('/repos/' + owner + '/' + repo);
|
||
if (!repoRes.ok) {
|
||
throw new Error('Could not read repository metadata: ' + repoRes.body);
|
||
}
|
||
const repoInfo = JSON.parse(repoRes.body);
|
||
const defaultBranch = repoInfo.default_branch;
|
||
|
||
const ctPath = 'ct/' + slugValue + '.sh';
|
||
const encodedCtPath = encodeContentPath(ctPath);
|
||
const defaultFileRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath + '?ref=' + encodeURIComponent(defaultBranch));
|
||
if (defaultFileRes.statusCode === 404) {
|
||
return { status: 'skipped', reason: 'No matching CT file found at `' + ctPath + '`.' };
|
||
}
|
||
if (!defaultFileRes.ok) {
|
||
throw new Error('Could not read CT file from default branch: ' + defaultFileRes.body);
|
||
}
|
||
|
||
const branchName = 'pocketbase-sync/' + sanitizeBranchPart(slugValue || 'unknown');
|
||
await ensureBranch(defaultBranch, branchName);
|
||
|
||
const branchFileRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath + '?ref=' + encodeURIComponent(branchName));
|
||
if (!branchFileRes.ok) {
|
||
throw new Error('Could not read CT file from sync branch: ' + branchFileRes.body);
|
||
}
|
||
const branchFile = JSON.parse(branchFileRes.body);
|
||
const currentBranchText = decodeGitHubContent(branchFile.content);
|
||
|
||
const updateResult = applyCtDefaultChanges(currentBranchText, Object.fromEntries(wantedEntries));
|
||
if (updateResult.updatedVars.length === 0) {
|
||
return { status: 'skipped', reason: 'CT defaults already up to date.', unchangedVars: updateResult.unchangedVars };
|
||
}
|
||
|
||
const commitMessage = 'chore(ct): sync ' + slugValue + ' defaults from PocketBase';
|
||
const putRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath, 'PUT', {
|
||
message: commitMessage,
|
||
content: Buffer.from(updateResult.nextText, 'utf8').toString('base64'),
|
||
sha: branchFile.sha,
|
||
branch: branchName
|
||
});
|
||
if (!putRes.ok) {
|
||
throw new Error('Could not update CT file: ' + putRes.body);
|
||
}
|
||
|
||
const openPrRes = await ghRequest(
|
||
'/repos/' + owner + '/' + repo + '/pulls?state=open&head=' + encodeURIComponent(owner + ':' + branchName) + '&base=' + encodeURIComponent(defaultBranch)
|
||
);
|
||
if (!openPrRes.ok) {
|
||
throw new Error('Could not query existing PRs: ' + openPrRes.body);
|
||
}
|
||
const openPrs = JSON.parse(openPrRes.body);
|
||
if (openPrs.length > 0) {
|
||
return { status: 'updated', prUrl: openPrs[0].html_url, updatedVars: updateResult.updatedVars };
|
||
}
|
||
|
||
const prTitle = 'chore(ct): sync ' + slugValue + ' defaults with PocketBase';
|
||
const prBody =
|
||
'## ✍️ Description\n\n' +
|
||
'Sync of the default CT variables for `' + slugValue + '` after a `/pocketbase` update by @' + actor + '.\n\n' +
|
||
'Updated: `' + updateResult.updatedVars.join('`, `') + '`\n\n' +
|
||
'## 🔗 Related Issue\n\n' +
|
||
(isPrComment ? 'Triggered from #' : 'Fixes #') + issueNumber + '\n\n' +
|
||
'## ✅ Prerequisites (**X** in brackets)\n\n' +
|
||
'- [x] **Self-review completed** – Code follows project standards.\n' +
|
||
'- [ ] **Tested thoroughly** – Changes work as expected.\n' +
|
||
'- [x] **No security risks** – No hardcoded secrets, unnecessary privilege escalations, or permission issues.\n\n' +
|
||
'**Tested on:** not tested — generated from the PocketBase record, only `var_` defaults changed.\n\n' +
|
||
'---\n\n' +
|
||
'## 🤖 AI Assistance (**X** in brackets)\n\n' +
|
||
'- [x] **No AI used** – Scripts were written without AI assistance.\n\n' +
|
||
'---\n\n' +
|
||
'## 🛠️ Type of Change (**X** in brackets)\n\n' +
|
||
'- [x] 🐞 **Bug fix** – Resolves an issue without breaking functionality.\n';
|
||
const createPrRes = await ghRequest('/repos/' + owner + '/' + repo + '/pulls', 'POST', {
|
||
title: prTitle,
|
||
body: prBody,
|
||
head: branchName,
|
||
base: defaultBranch
|
||
});
|
||
if (!createPrRes.ok) {
|
||
throw new Error('Could not create PR: ' + createPrRes.body);
|
||
}
|
||
const pr = JSON.parse(createPrRes.body);
|
||
return { status: 'created', prUrl: pr.html_url, updatedVars: updateResult.updatedVars };
|
||
}
|
||
|
||
function formatCtSyncResult(syncResult) {
|
||
if (!syncResult) return '';
|
||
if (syncResult.status === 'created') return '\n\n**CT sync PR:** ' + syncResult.prUrl;
|
||
if (syncResult.status === 'updated') return '\n\n**CT sync PR updated:** ' + syncResult.prUrl;
|
||
if (syncResult.status === 'skipped') return '\n\n**CT sync skipped:** ' + syncResult.reason;
|
||
return '';
|
||
}
|
||
|
||
async function addReaction(content) {
|
||
try {
|
||
await ghRequest(
|
||
'/repos/' + owner + '/' + repo + '/issues/comments/' + commentId + '/reactions',
|
||
'POST', { content }
|
||
);
|
||
} catch (e) {
|
||
console.warn('Could not add reaction:', e.message);
|
||
}
|
||
}
|
||
|
||
async function postComment(text) {
|
||
const res = await ghRequest(
|
||
'/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments',
|
||
'POST', { body: text }
|
||
);
|
||
if (!res.ok) console.warn('Could not post comment:', res.body);
|
||
}
|
||
|
||
// ── Locate the command line ────────────────────────────────────────
|
||
// Accept /pocketbase at the start of ANY line (leading whitespace ok),
|
||
// so the command works even when preceded by other text. Mid-sentence
|
||
// mentions and blockquoted ("> ...") examples are ignored.
|
||
const commentBody = process.env.COMMENT_BODY || '';
|
||
const cmdLine = commentBody
|
||
.split('\n')
|
||
.map(l => l.trim())
|
||
.find(l => l.startsWith('/pocketbase'));
|
||
|
||
if (!cmdLine) {
|
||
console.log('No /pocketbase command line found — ignoring comment.');
|
||
process.exit(0);
|
||
}
|
||
const withoutCmd = cmdLine.replace(/^\/pocketbase\s*/, '').trim();
|
||
|
||
// ── Permission check ───────────────────────────────────────────────
|
||
const association = process.env.ACTOR_ASSOCIATION;
|
||
if (association !== 'OWNER' && association !== 'MEMBER') {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: @' + actor + ' is not authorized to use this command.\n' +
|
||
'Only org members (Contributors team) can use `/pocketbase`.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── Acknowledge ────────────────────────────────────────────────────
|
||
await addReaction('eyes');
|
||
|
||
// ── Parse command ──────────────────────────────────────────────────
|
||
|
||
function extractCodeBlock(body) {
|
||
const m = body.match(/```[^\n]*\n([\s\S]*?)```/);
|
||
return m ? m[1].trim() : null;
|
||
}
|
||
const codeBlockValue = extractCodeBlock(commentBody);
|
||
|
||
const HELP_TEXT =
|
||
'**Show current state:**\n' +
|
||
'```\n/pocketbase <slug> info\n```\n\n' +
|
||
'**Field update (simple):** `/pocketbase <slug> field=value [field=value ...]`\n\n' +
|
||
'**Field update (HTML/multiline) — value from code block:**\n' +
|
||
'````\n' +
|
||
'/pocketbase <slug> set description\n' +
|
||
'```html\n' +
|
||
'<p>Your <b>HTML</b> or multi-line content here</p>\n' +
|
||
'```\n' +
|
||
'````\n\n' +
|
||
'**Note management:**\n' +
|
||
'```\n' +
|
||
'/pocketbase <slug> note list\n' +
|
||
'/pocketbase <slug> note add <type> "<text>"\n' +
|
||
'/pocketbase <slug> note edit <type> "<old text>" "<new text>"\n' +
|
||
'/pocketbase <slug> note remove <type> "<text>"\n' +
|
||
'```\n\n' +
|
||
'**Install method management:**\n' +
|
||
'```\n' +
|
||
'/pocketbase <slug> method list\n' +
|
||
'/pocketbase <slug> method <type> cpu=4 ram=2048 hdd=20\n' +
|
||
'/pocketbase <slug> method <type> config_path="/opt/app/.env"\n' +
|
||
'/pocketbase <slug> method <type> os=debian version=13\n' +
|
||
'/pocketbase <slug> method add <type> cpu=2 ram=2048 hdd=8 os=debian version=13\n' +
|
||
'/pocketbase <slug> method remove <type>\n' +
|
||
'```\n' +
|
||
'Method fields: `cpu` `ram` `hdd` `os` `version` `config_path` `script`\n\n' +
|
||
'`cpu` `ram` `hdd` `os` `version` also work without `method` — they go to the\n' +
|
||
'default install method, e.g. `/pocketbase <slug> hdd=25`\n\n' +
|
||
'**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `repository` ' +
|
||
'`config_path` `port` `default_user` `default_passwd` ' +
|
||
'`updateable` `privileged` `is_dev` `has_arm` ' +
|
||
'`architectures` (amd64,arm64) `platforms` (pve,incus) ' +
|
||
'`execute_in` (pve,lxc,pbs,vm,pmg,pdm) `categories` (names or ids) ' +
|
||
'`type` (ct, vm, addon, …) ' +
|
||
'`is_disabled` `disable_message` `is_deleted` `deleted_message` ' +
|
||
'`pin_reason` `last_update_commit`\n\n' +
|
||
'The `var_` names from the ct scripts work too: `var_cpu` `var_ram` `var_disk`\n' +
|
||
'`var_os` `var_version` `var_port` `var_tags` `var_unprivileged`\n\n' +
|
||
'**Screenshots:**\n' +
|
||
'```\n' +
|
||
'/pocketbase <slug> screenshot https://example.com/one.png https://example.com/two.png\n' +
|
||
'```\n\n' +
|
||
'`slug` is deliberately not editable: it is the URL, the JSON filename and the\n' +
|
||
'ct/<slug>.sh path all at once, so renaming it is a migration rather than an edit.';
|
||
|
||
if (!withoutCmd) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: No slug or command specified.\n\n' + HELP_TEXT);
|
||
process.exit(0);
|
||
}
|
||
|
||
const spaceIdx = withoutCmd.indexOf(' ');
|
||
const slug = (spaceIdx === -1 ? withoutCmd : withoutCmd.substring(0, spaceIdx)).trim();
|
||
const rest = spaceIdx === -1 ? '' : withoutCmd.substring(spaceIdx + 1).trim();
|
||
|
||
if (!rest) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: No command specified for slug `' + slug + '`.\n\n' + HELP_TEXT);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── PocketBase: authenticate ───────────────────────────────────────
|
||
const raw = process.env.POCKETBASE_URL.replace(/\/$/, '');
|
||
const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api';
|
||
const coll = process.env.POCKETBASE_COLLECTION;
|
||
|
||
const authRes = await request(apiBase + '/collections/users/auth-with-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
identity: process.env.POCKETBASE_ADMIN_EMAIL,
|
||
password: process.env.POCKETBASE_ADMIN_PASSWORD
|
||
})
|
||
});
|
||
if (!authRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: PocketBase authentication failed. CC @' + owner + '/maintainers');
|
||
process.exit(1);
|
||
}
|
||
const token = JSON.parse(authRes.body).token;
|
||
|
||
// ── PocketBase: find record by slug ────────────────────────────────
|
||
const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
|
||
const filter = "(slug='" + slug.replace(/'/g, "''") + "')";
|
||
const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', {
|
||
headers: { 'Authorization': token }
|
||
});
|
||
const list = JSON.parse(listRes.body);
|
||
const record = list.items && list.items[0];
|
||
|
||
if (!record) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No record found for slug `' + slug + '`.\n\n' +
|
||
'Make sure the script was already pushed to PocketBase (JSON must exist and have been synced).'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── Shared helpers ─────────────────────────────────────────────────
|
||
|
||
// The ct scripts name these var_cpu/var_ram/var_disk, and that is what
|
||
// people type. Accept them for the PocketBase keys.
|
||
const KEY_ALIASES = {
|
||
var_cpu: 'cpu', var_ram: 'ram', var_disk: 'hdd', var_hdd: 'hdd',
|
||
var_os: 'os', var_version: 'version', var_unprivileged: 'unprivileged',
|
||
var_port: 'port', var_tags: 'tags', disk: 'hdd', memory: 'ram',
|
||
};
|
||
|
||
// Key=value parser: handles unquoted and "quoted" values
|
||
function parseKVPairs(str) {
|
||
const fields = {};
|
||
let pos = 0;
|
||
while (pos < str.length) {
|
||
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||
if (pos >= str.length) break;
|
||
let keyStart = pos;
|
||
while (pos < str.length && str[pos] !== '=' && !/\s/.test(str[pos])) pos++;
|
||
const key = str.substring(keyStart, pos).trim();
|
||
if (!key || pos >= str.length || str[pos] !== '=') { pos++; continue; }
|
||
pos++;
|
||
let value;
|
||
if (pos < str.length && str[pos] === '"') {
|
||
pos++;
|
||
let valStart = pos;
|
||
while (pos < str.length && str[pos] !== '"') {
|
||
if (str[pos] === '\\') pos++;
|
||
pos++;
|
||
}
|
||
value = str.substring(valStart, pos).replace(/\\"/g, '"');
|
||
if (pos < str.length) pos++;
|
||
} else {
|
||
let valStart = pos;
|
||
while (pos < str.length && !/\s/.test(str[pos])) pos++;
|
||
value = str.substring(valStart, pos);
|
||
}
|
||
fields[KEY_ALIASES[key.toLowerCase()] || key] = value;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
// Token parser for note commands: unquoted-word OR "quoted string"
|
||
function parseTokens(str) {
|
||
const tokens = [];
|
||
let pos = 0;
|
||
while (pos < str.length) {
|
||
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||
if (pos >= str.length) break;
|
||
if (str[pos] === '"') {
|
||
pos++;
|
||
let start = pos;
|
||
while (pos < str.length && str[pos] !== '"') {
|
||
if (str[pos] === '\\') pos++;
|
||
pos++;
|
||
}
|
||
tokens.push(str.substring(start, pos).replace(/\\"/g, '"'));
|
||
if (pos < str.length) pos++;
|
||
} else {
|
||
let start = pos;
|
||
while (pos < str.length && !/\s/.test(str[pos])) pos++;
|
||
tokens.push(str.substring(start, pos));
|
||
}
|
||
}
|
||
return tokens;
|
||
}
|
||
|
||
// Read JSON blob from record (handles parsed objects and strings)
|
||
function readJsonBlob(val) {
|
||
if (Array.isArray(val)) return val;
|
||
try { return JSON.parse(val || '[]'); } catch (e) { return []; }
|
||
}
|
||
|
||
// Frontend cache revalidation (silent, best-effort)
|
||
async function revalidate(s) {
|
||
const frontendUrl = process.env.FRONTEND_URL;
|
||
const secret = process.env.REVALIDATE_SECRET;
|
||
if (!frontendUrl || !secret) return;
|
||
try {
|
||
await request(frontendUrl.replace(/\/$/, '') + '/api/revalidate', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + secret, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tags: ['scripts', 'script-' + s] })
|
||
});
|
||
} catch (e) { console.warn('Revalidation skipped:', e.message); }
|
||
}
|
||
|
||
// Format notes list for display
|
||
function formatNotesList(arr) {
|
||
if (arr.length === 0) return '*None*';
|
||
return arr.map(function (n, i) {
|
||
return (i + 1) + '. **`' + (n.type || '?') + '`**: ' + (n.text || '');
|
||
}).join('\n');
|
||
}
|
||
|
||
// Format install methods list for display
|
||
function formatMethodsList(arr) {
|
||
if (arr.length === 0) return '*None*';
|
||
return arr.map(function (im, i) {
|
||
const r = im.resources || {};
|
||
const parts = [
|
||
(r.os || '?') + ' ' + (r.version || '?'),
|
||
(r.cpu != null ? r.cpu : '?') + 'C / ' + (r.ram != null ? r.ram : '?') + ' MB / ' + (r.hdd != null ? r.hdd : '?') + ' GB'
|
||
];
|
||
if (im.config_path) parts.push('config: `' + im.config_path + '`');
|
||
if (im.script) parts.push('script: `' + im.script + '`');
|
||
return (i + 1) + '. **`' + (im.type || '?') + '`** — ' + parts.join(', ');
|
||
}).join('\n');
|
||
}
|
||
|
||
// ── Route: dispatch to subcommand handler ──────────────────────────
|
||
const infoMatch = rest.match(/^info$/i);
|
||
const noteMatch = rest.match(/^note\s+(list|add|edit|remove)\b/i);
|
||
const methodMatch = rest.match(/^method\b/i);
|
||
const setMatch = rest.match(/^set\s+(\S+)/i);
|
||
const shotMatch = rest.match(/^screenshots?\s+(.+)$/i);
|
||
|
||
if (infoMatch) {
|
||
// ── INFO SUBCOMMAND ──────────────────────────────────────────────
|
||
const notesArr = readJsonBlob(record.notes);
|
||
const methodsArr = readJsonBlob(record.install_methods);
|
||
|
||
const out = [];
|
||
out.push('ℹ️ **PocketBase Bot**: Info for **`' + slug + '`**\n');
|
||
|
||
out.push('**Basic info:**');
|
||
out.push('- **Name:** ' + (record.name || '—'));
|
||
out.push('- **Slug:** `' + slug + '`');
|
||
out.push('- **Port:** ' + (record.port != null ? '`' + record.port + '`' : '—'));
|
||
out.push('- **Updateable:** ' + (record.updateable ? 'Yes' : 'No'));
|
||
out.push('- **Privileged:** ' + (record.privileged ? 'Yes' : 'No'));
|
||
out.push('- **Architectures:** ' + ((record.architectures || []).join(', ') || 'amd64'));
|
||
out.push('- **Platforms:** ' + ((record.platforms || []).join(', ') || 'pve'));
|
||
if (record.is_dev) out.push('- **Dev:** Yes');
|
||
if (record.is_disabled) out.push('- **Disabled:** Yes' + (record.disable_message ? ' — ' + record.disable_message : ''));
|
||
if (record.is_deleted) out.push('- **Deleted:** Yes' + (record.deleted_message ? ' — ' + record.deleted_message : ''));
|
||
out.push('');
|
||
|
||
out.push('**Links:**');
|
||
out.push('- **Website:** ' + (record.website || '—'));
|
||
out.push('- **Docs:** ' + (record.documentation || '—'));
|
||
out.push('- **Logo:** ' + (record.logo ? '[link](' + record.logo + ')' : '—'));
|
||
out.push('- **GitHub:** ' + (record.github || '—'));
|
||
if (record.config_path) out.push('- **Config:** `' + record.config_path + '`');
|
||
out.push('');
|
||
|
||
out.push('**Credentials:**');
|
||
out.push('- **User:** ' + (record.default_user || '—'));
|
||
out.push('- **Password:** ' + (record.default_passwd ? '*(set)*' : '—'));
|
||
out.push('');
|
||
|
||
out.push('**Install methods** (' + methodsArr.length + '):');
|
||
out.push(formatMethodsList(methodsArr));
|
||
out.push('');
|
||
|
||
out.push('**Notes** (' + notesArr.length + '):');
|
||
out.push(formatNotesList(notesArr));
|
||
|
||
await addReaction('+1');
|
||
await postComment(out.join('\n'));
|
||
|
||
} else if (shotMatch) {
|
||
// ── SCREENSHOT SUBCOMMAND ────────────────────────────────────────
|
||
// Delegated to the frontend rather than reimplemented here: it
|
||
// already fetches the URL, checks the content type and size, and
|
||
// attaches the file to PocketBase. Doing that a second time in a
|
||
// workflow would be a second set of bugs.
|
||
const shotUrls = shotMatch[1].split(/[\s,]+/).map(function (u) { return u.trim(); }).filter(Boolean);
|
||
const bad = shotUrls.filter(function (u) { return !/^https?:\/\//i.test(u); });
|
||
if (bad.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: not a URL: `' + bad.join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
const frontendUrl = process.env.FRONTEND_URL;
|
||
const shotSecret = process.env.SCREENSHOT_IMPORT_SECRET;
|
||
if (!frontendUrl || !shotSecret) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: screenshot import is not configured (FRONTEND_URL / SCREENSHOT_IMPORT_SECRET).');
|
||
process.exit(1);
|
||
}
|
||
const shotRes = await request(frontendUrl.replace(/\/$/, '') + '/api/screenshots', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ slug: slug, secret: shotSecret, urls: shotUrls })
|
||
});
|
||
if (!shotRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: screenshot import failed:\n```\n' + shotRes.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: attached ' + shotUrls.length + ' screenshot' + (shotUrls.length === 1 ? '' : 's') +
|
||
' to **`' + slug + '`**\n\n' + shotUrls.map(function (u) { return '- ' + u; }).join('\n') +
|
||
'\n\n*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (noteMatch) {
|
||
// ── NOTE SUBCOMMAND ──────────────────────────────────────────────
|
||
const noteAction = noteMatch[1].toLowerCase();
|
||
const noteArgsStr = rest.substring(noteMatch[0].length).trim();
|
||
let notesArr = readJsonBlob(record.notes);
|
||
|
||
async function patchNotes(arr) {
|
||
const res = await request(recordsUrl + '/' + record.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ notes: arr })
|
||
});
|
||
if (!res.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Failed to update notes:\n```\n' + res.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (noteAction === 'list') {
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'ℹ️ **PocketBase Bot**: Notes for **`' + slug + '`** (' + notesArr.length + ' total)\n\n' +
|
||
formatNotesList(notesArr)
|
||
);
|
||
|
||
} else if (noteAction === 'add') {
|
||
const tokens = parseTokens(noteArgsStr);
|
||
if (tokens.length < 2) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `note add` requires `<type>` and `"<text>"`.\n\n' +
|
||
'**Usage:** `/pocketbase ' + slug + ' note add <type> "<text>"`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const noteType = tokens[0].toLowerCase();
|
||
const noteText = tokens.slice(1).join(' ');
|
||
notesArr.push({ type: noteType, text: noteText });
|
||
await patchNotes(notesArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Added note to **`' + slug + '`**\n\n' +
|
||
'- **Type:** `' + noteType + '`\n' +
|
||
'- **Text:** ' + noteText + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (noteAction === 'edit') {
|
||
const tokens = parseTokens(noteArgsStr);
|
||
if (tokens.length < 3) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `note edit` requires `<type>`, `"<old text>"`, and `"<new text>"`.\n\n' +
|
||
'**Usage:** `/pocketbase ' + slug + ' note edit <type> "<old text>" "<new text>"`\n\n' +
|
||
'Use `/pocketbase ' + slug + ' note list` to see current notes.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const noteType = tokens[0].toLowerCase();
|
||
const oldText = tokens[1];
|
||
const newText = tokens[2];
|
||
const idx = notesArr.findIndex(function (n) {
|
||
return n.type.toLowerCase() === noteType && n.text === oldText;
|
||
});
|
||
if (idx === -1) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' +
|
||
'**Current notes for `' + slug + '`:**\n' + formatNotesList(notesArr)
|
||
);
|
||
process.exit(0);
|
||
}
|
||
notesArr[idx].text = newText;
|
||
await patchNotes(notesArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Edited note in **`' + slug + '`**\n\n' +
|
||
'- **Type:** `' + noteType + '`\n' +
|
||
'- **Old:** ' + oldText + '\n' +
|
||
'- **New:** ' + newText + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (noteAction === 'remove') {
|
||
const tokens = parseTokens(noteArgsStr);
|
||
if (tokens.length < 2) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `note remove` requires `<type>` and `"<text>"`.\n\n' +
|
||
'**Usage:** `/pocketbase ' + slug + ' note remove <type> "<text>"`\n\n' +
|
||
'Use `/pocketbase ' + slug + ' note list` to see current notes.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const noteType = tokens[0].toLowerCase();
|
||
const noteText = tokens[1];
|
||
const before = notesArr.length;
|
||
notesArr = notesArr.filter(function (n) {
|
||
return !(n.type.toLowerCase() === noteType && n.text === noteText);
|
||
});
|
||
if (notesArr.length === before) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' +
|
||
'**Current notes for `' + slug + '`:**\n' + formatNotesList(notesArr)
|
||
);
|
||
process.exit(0);
|
||
}
|
||
await patchNotes(notesArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Removed note from **`' + slug + '`**\n\n' +
|
||
'- **Type:** `' + noteType + '`\n' +
|
||
'- **Text:** ' + noteText + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
}
|
||
|
||
} else if (methodMatch) {
|
||
// ── METHOD SUBCOMMAND ────────────────────────────────────────────
|
||
const methodArgs = rest.replace(/^method\s*/i, '').trim();
|
||
const methodListMode = !methodArgs || methodArgs.toLowerCase() === 'list';
|
||
let methodsArr = readJsonBlob(record.install_methods);
|
||
|
||
// Method field classification
|
||
const RESOURCE_KEYS = { cpu: 'number', ram: 'number', hdd: 'number', os: 'string', version: 'string' };
|
||
const METHOD_KEYS = { config_path: 'string', script: 'string' };
|
||
const ALL_METHOD_KEYS = Object.assign({}, RESOURCE_KEYS, METHOD_KEYS);
|
||
const RESOURCE_TO_CT_VAR = { cpu: 'var_cpu', ram: 'var_ram', hdd: 'var_disk', os: 'var_os', version: 'var_version' };
|
||
|
||
function applyMethodChanges(method, parsed) {
|
||
if (!method.resources) method.resources = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (RESOURCE_KEYS[k]) {
|
||
method.resources[k] = RESOURCE_KEYS[k] === 'number' ? parseInt(v, 10) : v;
|
||
} else if (METHOD_KEYS[k]) {
|
||
method[k] = v === '' ? null : v;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function patchMethods(arr) {
|
||
const res = await request(recordsUrl + '/' + record.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ install_methods: arr })
|
||
});
|
||
if (!res.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Failed to update install methods:\n```\n' + res.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (methodListMode) {
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'ℹ️ **PocketBase Bot**: Install methods for **`' + slug + '`** (' + methodsArr.length + ' total)\n\n' +
|
||
formatMethodsList(methodsArr)
|
||
);
|
||
|
||
} else {
|
||
// Check for add / remove sub-actions
|
||
const addMatch = methodArgs.match(/^add\s+(\S+)(?:\s+(.+))?$/i);
|
||
const removeMatch = methodArgs.match(/^remove\s+(\S+)$/i);
|
||
|
||
if (addMatch) {
|
||
// ── METHOD ADD ───────────────────────────────────────────────
|
||
const newType = addMatch[1];
|
||
const parsed = addMatch[2] ? parseKVPairs(addMatch[2]) : {};
|
||
if (methodsArr.some(function (im) { return (im.type || '').toLowerCase() === newType.toLowerCase(); })) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Install method `' + newType + '` already exists for `' + slug + '`.\n\nUse `/pocketbase ' + slug + ' method list` to see all methods.');
|
||
process.exit(0);
|
||
}
|
||
const newMethod = { type: newType, resources: { cpu: 1, ram: 512, hdd: 4, os: 'debian', version: '13' } };
|
||
if (addMatch[2]) {
|
||
const unknown = Object.keys(parsed).filter(function (k) { return !ALL_METHOD_KEYS[k]; });
|
||
if (unknown.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Unknown method field(s): `' + unknown.join('`, `') + '`\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
applyMethodChanges(newMethod, parsed);
|
||
}
|
||
methodsArr.push(newMethod);
|
||
await patchMethods(methodsArr);
|
||
await revalidate(slug);
|
||
const addCtChanges = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (RESOURCE_TO_CT_VAR[k]) addCtChanges[RESOURCE_TO_CT_VAR[k]] = v;
|
||
}
|
||
let addCtSync = null;
|
||
try {
|
||
addCtSync = await upsertCtDefaultsPr(slug, addCtChanges);
|
||
} catch (e) {
|
||
addCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
|
||
}
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Added install method **`' + newType + '`** to **`' + slug + '`**\n\n' +
|
||
formatMethodsList([newMethod]) + '\n\n' +
|
||
formatCtSyncResult(addCtSync) + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (removeMatch) {
|
||
// ── METHOD REMOVE ────────────────────────────────────────────
|
||
const removeType = removeMatch[1].toLowerCase();
|
||
const removed = methodsArr.filter(function (im) { return (im.type || '').toLowerCase() === removeType; });
|
||
if (removed.length === 0) {
|
||
await addReaction('-1');
|
||
const available = methodsArr.map(function (im) { return im.type || '?'; });
|
||
await postComment('❌ **PocketBase Bot**: No install method `' + removeType + '` found.\n\n**Available:** `' + (available.length ? available.join('`, `') : '(none)') + '`');
|
||
process.exit(0);
|
||
}
|
||
methodsArr = methodsArr.filter(function (im) { return (im.type || '').toLowerCase() !== removeType; });
|
||
await patchMethods(methodsArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Removed install method **`' + removed[0].type + '`** from **`' + slug + '`**\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else {
|
||
// ── METHOD EDIT ──────────────────────────────────────────────
|
||
const editParts = methodArgs.match(/^(\S+)\s+(.+)$/);
|
||
if (!editParts) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: Invalid `method` syntax.\n\n' +
|
||
'**Usage:**\n```\n/pocketbase ' + slug + ' method list\n' +
|
||
'/pocketbase ' + slug + ' method <type> cpu=4 ram=2048 hdd=20\n' +
|
||
'/pocketbase ' + slug + ' method <type> config_path="/opt/app/.env"\n' +
|
||
'/pocketbase ' + slug + ' method add <type> cpu=2 ram=2048 hdd=8\n' +
|
||
'/pocketbase ' + slug + ' method remove <type>\n```'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const targetType = editParts[1].toLowerCase();
|
||
const parsed = parseKVPairs(editParts[2]);
|
||
|
||
const unknown = Object.keys(parsed).filter(function (k) { return !ALL_METHOD_KEYS[k]; });
|
||
if (unknown.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Unknown method field(s): `' + unknown.join('`, `') + '`\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
if (Object.keys(parsed).length === 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: No valid `key=value` pairs found.\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
|
||
const idx = methodsArr.findIndex(function (im) { return (im.type || '').toLowerCase() === targetType; });
|
||
if (idx === -1) {
|
||
await addReaction('-1');
|
||
const available = methodsArr.map(function (im) { return im.type || '?'; });
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No install method `' + targetType + '` found for `' + slug + '`.\n\n' +
|
||
'**Available:** `' + (available.length ? available.join('`, `') : '(none)') + '`\n\n' +
|
||
'Use `/pocketbase ' + slug + ' method list` to see all methods.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
applyMethodChanges(methodsArr[idx], parsed);
|
||
await patchMethods(methodsArr);
|
||
await revalidate(slug);
|
||
const editCtChanges = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (RESOURCE_TO_CT_VAR[k]) editCtChanges[RESOURCE_TO_CT_VAR[k]] = v;
|
||
}
|
||
let editCtSync = null;
|
||
try {
|
||
editCtSync = await upsertCtDefaultsPr(slug, editCtChanges);
|
||
} catch (e) {
|
||
editCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
|
||
}
|
||
|
||
const changesLines = Object.entries(parsed)
|
||
.map(function ([k, v]) {
|
||
const unit = k === 'ram' ? ' MB' : k === 'hdd' ? ' GB' : '';
|
||
return '- `' + k + '` → `' + v + unit + '`';
|
||
}).join('\n');
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Updated install method **`' + methodsArr[idx].type + '`** for **`' + slug + '`**\n\n' +
|
||
'**Changes applied:**\n' + changesLines + '\n\n' +
|
||
formatCtSyncResult(editCtSync) + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
}
|
||
}
|
||
|
||
} else if (setMatch) {
|
||
// ── SET SUBCOMMAND (value from code block) ───────────────────────
|
||
const fieldName = setMatch[1].toLowerCase();
|
||
// app_vars is a JSON column, so it rides the code-block path like
|
||
// the long text fields - but its content is parsed, not stored
|
||
// verbatim, or PocketBase ends up with a string where an object
|
||
// belongs and every reader has to guess.
|
||
const SET_ALLOWED = {
|
||
name: 'string', description: 'string', logo: 'string',
|
||
documentation: 'string', website: 'string', project_url: 'string', repository: 'string',
|
||
config_path: 'string', disable_message: 'string', deleted_message: 'string',
|
||
app_vars: 'json'
|
||
};
|
||
if (!SET_ALLOWED[fieldName]) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `set` only supports text and JSON fields.\n\n' +
|
||
'**Allowed:** `' + Object.keys(SET_ALLOWED).join('`, `') + '`\n\n' +
|
||
'For boolean/number fields use `field=value` syntax instead.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
if (!codeBlockValue) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `set` requires a code block with the value.\n\n' +
|
||
'**Usage:**\n````\n/pocketbase ' + slug + ' set ' + fieldName + '\n```\nYour content here (HTML, multiline, special chars all fine)\n```\n````'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const setPayload = {};
|
||
if (SET_ALLOWED[fieldName] === 'json') {
|
||
let parsedJson;
|
||
try {
|
||
parsedJson = JSON.parse(codeBlockValue);
|
||
} catch (e) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `' + fieldName + '` must be valid JSON.\n\n```\n' + e.message + '\n```'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
setPayload[fieldName] = parsedJson;
|
||
} else {
|
||
setPayload[fieldName] = codeBlockValue;
|
||
}
|
||
const setPatchRes = await request(recordsUrl + '/' + record.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(setPayload)
|
||
});
|
||
if (!setPatchRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + setPatchRes.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
await revalidate(slug);
|
||
const preview = codeBlockValue.length > 300 ? codeBlockValue.substring(0, 300) + '…' : codeBlockValue;
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Set `' + fieldName + '` for **`' + slug + '`**\n\n' +
|
||
'**Value set:**\n```\n' + preview + '\n```\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else {
|
||
// ── FIELD=VALUE PATH ─────────────────────────────────────────────
|
||
const ALLOWED_FIELDS = {
|
||
name: 'string',
|
||
description: 'string',
|
||
logo: 'string',
|
||
documentation: 'string',
|
||
website: 'string',
|
||
project_url: 'string',
|
||
repository: 'string',
|
||
config_path: 'string',
|
||
tags: 'string',
|
||
port: 'number',
|
||
default_user: 'nullable_string',
|
||
default_passwd: 'nullable_string',
|
||
unprivileged: 'number',
|
||
updateable: 'boolean',
|
||
privileged: 'boolean',
|
||
architectures: 'select_list',
|
||
platforms: 'select_list',
|
||
execute_in: 'select_list',
|
||
has_arm: 'boolean',
|
||
categories: 'relation_list',
|
||
type: 'relation_single',
|
||
is_dev: 'boolean',
|
||
is_disabled: 'boolean',
|
||
disable_message: 'string',
|
||
is_deleted: 'boolean',
|
||
deleted_message: 'string',
|
||
pin_reason: 'string',
|
||
last_update_commit: 'string',
|
||
};
|
||
|
||
const parsedFields = parseKVPairs(rest);
|
||
|
||
// cpu/ram/hdd/os/version live inside install_methods, not on the
|
||
// record itself. "/pocketbase immich hdd=25" is how people ask for
|
||
// it, so route those keys there instead of rejecting them as
|
||
// unknown fields. Everything else stays a plain record field.
|
||
const TOP_RESOURCE_KEYS = { cpu: 'number', ram: 'number', hdd: 'number', os: 'string', version: 'string' };
|
||
const resourceFields = {};
|
||
const scriptFields = {};
|
||
for (const [rk, rv] of Object.entries(parsedFields)) {
|
||
if (TOP_RESOURCE_KEYS[rk]) resourceFields[rk] = rv; else scriptFields[rk] = rv;
|
||
}
|
||
|
||
const unknownFields = Object.keys(scriptFields).filter(function (f) { return !ALLOWED_FIELDS[f]; });
|
||
if (unknownFields.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: Unknown field(s): `' + unknownFields.join('`, `') + '`\n\n' +
|
||
'**Allowed fields:** `' + Object.keys(ALLOWED_FIELDS).join('`, `') + '`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
if (Object.keys(parsedFields).length === 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Could not parse any valid `field=value` pairs.\n\n' + HELP_TEXT);
|
||
process.exit(0);
|
||
}
|
||
|
||
// Cast values to correct types
|
||
const SELECT_VALUES = {
|
||
architectures: ['amd64', 'arm64'],
|
||
platforms: ['pve', 'incus'],
|
||
execute_in: ['pve', 'lxc', 'pbs', 'vm', 'pmg', 'pdm'],
|
||
};
|
||
const payload = {};
|
||
for (const [key, rawVal] of Object.entries(scriptFields)) {
|
||
const type = ALLOWED_FIELDS[key];
|
||
if (type === 'boolean') {
|
||
if (rawVal === 'true') payload[key] = true;
|
||
else if (rawVal === 'false') payload[key] = false;
|
||
else {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + key + '` must be `true` or `false`, got: `' + rawVal + '`');
|
||
process.exit(0);
|
||
}
|
||
} else if (type === 'number') {
|
||
const n = parseInt(rawVal, 10);
|
||
if (isNaN(n)) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + key + '` must be a number, got: `' + rawVal + '`');
|
||
process.exit(0);
|
||
}
|
||
payload[key] = n;
|
||
} else if (type === 'select_list') {
|
||
// architectures/platforms are multi-selects over a closed set,
|
||
// so an unknown value is rejected rather than stored.
|
||
const allowed = SELECT_VALUES[key] || [];
|
||
const values = rawVal.split(',').map(function (v) { return v.trim(); }).filter(Boolean);
|
||
const bad = values.filter(function (v) { return allowed.indexOf(v) === -1; });
|
||
if (bad.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + key + '` accepts ' + allowed.join(', ') + ' — got: `' + bad.join(', ') + '`');
|
||
process.exit(0);
|
||
}
|
||
payload[key] = values;
|
||
} else if (type === 'relation_list') {
|
||
// categories is a relation, so the stored value is a list of
|
||
// record ids. People write category names, so resolve them —
|
||
// and accept ids too, which is what a copy out of the
|
||
// PocketBase UI gives you.
|
||
const wanted = rawVal.split(',').map(function (v) { return v.trim(); }).filter(Boolean);
|
||
const catRes = await request(apiBase + '/collections/script_categories/records?perPage=500&fields=id,name', {
|
||
headers: { 'Authorization': token }
|
||
});
|
||
if (!catRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: could not read `script_categories` to resolve `' + key + '`.');
|
||
process.exit(1);
|
||
}
|
||
const cats = JSON.parse(catRes.body).items || [];
|
||
const byName = {};
|
||
const ids = {};
|
||
cats.forEach(function (c) { byName[String(c.name).toLowerCase()] = c.id; ids[c.id] = true; });
|
||
const resolved = [];
|
||
const unknownCats = [];
|
||
wanted.forEach(function (w) {
|
||
if (ids[w]) { resolved.push(w); return; }
|
||
const id = byName[w.toLowerCase()];
|
||
if (id) resolved.push(id); else unknownCats.push(w);
|
||
});
|
||
if (unknownCats.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: unknown ' + key + ': `' + unknownCats.join('`, `') + '`\n\n' +
|
||
'**Available:** `' + cats.map(function (c) { return c.name; }).sort().join('`, `') + '`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
payload[key] = resolved;
|
||
} else if (type === 'relation_single') {
|
||
// type points at one z_ref_script_types record. People write
|
||
// "ct" or "vm", not a 15-character id.
|
||
const typeRes = await request(apiBase + '/collections/z_ref_script_types/records?perPage=200&fields=id,type', {
|
||
headers: { 'Authorization': token }
|
||
});
|
||
if (!typeRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: could not read `z_ref_script_types` to resolve `' + key + '`.');
|
||
process.exit(1);
|
||
}
|
||
const types = JSON.parse(typeRes.body).items || [];
|
||
const wantType = rawVal.trim().toLowerCase();
|
||
const hit = types.find(function (t) {
|
||
return t.id === rawVal.trim() || String(t.type).toLowerCase() === wantType;
|
||
});
|
||
if (!hit) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: unknown type `' + rawVal + '`\n\n' +
|
||
'**Available:** `' + types.map(function (t) { return t.type; }).sort().join('`, `') + '`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
payload[key] = hit.id;
|
||
} else if (type === 'nullable_string') {
|
||
payload[key] = rawVal === '' ? null : rawVal;
|
||
} else {
|
||
payload[key] = rawVal;
|
||
}
|
||
}
|
||
|
||
// Resources go into install_methods. Which method: the only one if
|
||
// there is only one, otherwise the non-Alpine default — and the
|
||
// reply says which, so nobody has to guess where 25 GB landed.
|
||
const resourceKeys = Object.keys(resourceFields);
|
||
let resourceTargetType = null;
|
||
if (resourceKeys.length > 0) {
|
||
const methodsArr = readJsonBlob(record.install_methods) || [];
|
||
if (methodsArr.length === 0) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `' + slug + '` has no install methods, so there is nowhere to put `' +
|
||
resourceKeys.join('`, `') + '`.\n\nAdd one first: `/pocketbase ' + slug + ' method add default cpu=2 ram=2048 hdd=8`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
let target = methodsArr.find(function (m) {
|
||
return String(m.type || '').toLowerCase() !== 'alpine';
|
||
}) || methodsArr[0];
|
||
resourceTargetType = target.type || 'default';
|
||
if (!target.resources) target.resources = {};
|
||
for (const [rk, rv] of Object.entries(resourceFields)) {
|
||
if (TOP_RESOURCE_KEYS[rk] === 'number') {
|
||
const n = parseInt(rv, 10);
|
||
if (isNaN(n)) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + rk + '` must be a number, got: `' + rv + '`');
|
||
process.exit(0);
|
||
}
|
||
target.resources[rk] = n;
|
||
} else {
|
||
target.resources[rk] = rv;
|
||
}
|
||
}
|
||
payload.install_methods = methodsArr;
|
||
}
|
||
|
||
const patchRes = await request(recordsUrl + '/' + record.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!patchRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + patchRes.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
await revalidate(slug);
|
||
const FIELD_TO_CT_VAR = { tags: 'var_tags', unprivileged: 'var_unprivileged' };
|
||
const RESOURCE_CT_VAR = { cpu: 'var_cpu', ram: 'var_ram', hdd: 'var_disk', os: 'var_os', version: 'var_version' };
|
||
const fieldCtChanges = {};
|
||
for (const [k, v] of Object.entries(payload)) {
|
||
if (FIELD_TO_CT_VAR[k]) fieldCtChanges[FIELD_TO_CT_VAR[k]] = v;
|
||
}
|
||
// Resources belong in the CT script too, exactly as the `method`
|
||
// path syncs them - otherwise PocketBase and ct/<slug>.sh drift.
|
||
for (const [k, v] of Object.entries(resourceFields)) {
|
||
if (RESOURCE_CT_VAR[k]) fieldCtChanges[RESOURCE_CT_VAR[k]] = v;
|
||
}
|
||
let fieldCtSync = null;
|
||
try {
|
||
fieldCtSync = await upsertCtDefaultsPr(slug, fieldCtChanges);
|
||
} catch (e) {
|
||
fieldCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
|
||
}
|
||
await addReaction('+1');
|
||
// install_methods is skipped here: dumping the whole array as JSON
|
||
// buries the one number that actually changed.
|
||
const changesLines = Object.entries(payload)
|
||
.filter(function (e) { return e[0] !== 'install_methods'; })
|
||
.map(function ([k, v]) { return '- `' + k + '` → `' + JSON.stringify(v) + '`'; })
|
||
.concat(Object.entries(resourceFields).map(function ([k, v]) {
|
||
return '- `' + k + '` → `' + v + '` *(install method `' + resourceTargetType + '`)*';
|
||
}))
|
||
.join('\n');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Updated **`' + slug + '`** successfully!\n\n' +
|
||
'**Changes applied:**\n' + changesLines + '\n\n' +
|
||
formatCtSyncResult(fieldCtSync) + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
}
|
||
|
||
console.log('Done.');
|
||
})().catch(function (e) {
|
||
console.error('Fatal error:', e.message || e);
|
||
process.exit(1);
|
||
});
|
||
ENDSCRIPT
|
||
shell: bash
|