diff --git a/.github/workflows/pocketbase-ai-bot.yml b/.github/workflows/pocketbase-ai-bot.yml index e3840ea64..5ee005c6f 100644 --- a/.github/workflows/pocketbase-ai-bot.yml +++ b/.github/workflows/pocketbase-ai-bot.yml @@ -314,12 +314,17 @@ jobs: // ── Allow-lists (mirror the slash bot) ───────────────────────────── const ALLOWED_FIELDS = { name: 'string', description: 'string', logo: 'string', documentation: 'string', - website: 'string', project_url: 'string', github: 'string', config_path: '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', has_arm: 'boolean', + unprivileged: 'number', updateable: 'boolean', privileged: 'boolean', + architectures: 'select_list', platforms: 'select_list', is_dev: 'boolean', is_disabled: 'boolean', disable_message: 'string', is_deleted: 'boolean', deleted_message: 'string' }; + const SELECT_VALUES = { + architectures: ['amd64', 'arm64'], + platforms: ['pve', 'incus'], + }; const FIELD_TO_CT_VAR = { tags: 'var_tags', unprivileged: 'var_unprivileged' }; const RESOURCE_KEYS = { cpu: 'number', ram: 'number', hdd: 'number', os: 'string', version: 'string' }; const METHOD_KEYS = { config_path: 'string', script: 'string' }; @@ -339,6 +344,17 @@ jobs: if (isNaN(n)) return { error: '`' + key + '` must be a number' }; return { value: n }; } + if (type === 'select_list') { + // architectures/platforms are multi-selects over a closed set, + // so an unknown value is rejected rather than stored. The model + // may hand back an array or a comma-separated string. + const allowed = SELECT_VALUES[key] || []; + const raw = Array.isArray(rawVal) ? rawVal : String(rawVal == null ? '' : rawVal).split(','); + const values = raw.map(function (v) { return String(v).trim(); }).filter(Boolean); + const bad = values.filter(function (v) { return allowed.indexOf(v) === -1; }); + if (bad.length > 0) return { error: '`' + key + '` accepts ' + allowed.join(', ') + ' — got: ' + bad.join(', ') }; + return { value: values }; + } if (type === 'nullable_string') return { value: rawVal === '' || rawVal == null ? null : String(rawVal) }; return { value: String(rawVal) }; } diff --git a/.github/workflows/pocketbase-bot.yml b/.github/workflows/pocketbase-bot.yml index 9d824348d..b22b0755e 100644 --- a/.github/workflows/pocketbase-bot.yml +++ b/.github/workflows/pocketbase-bot.yml @@ -323,9 +323,10 @@ jobs: '/pocketbase method remove \n' + '```\n' + 'Method fields: `cpu` `ram` `hdd` `os` `version` `config_path` `script`\n\n' + - '**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `github` ' + + '**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `repository` ' + '`config_path` `port` `default_user` `default_passwd` ' + - '`updateable` `privileged` `has_arm` `is_dev` ' + + '`updateable` `privileged` `is_dev` ' + + '`architectures` (amd64,arm64) `platforms` (pve,incus) ' + '`is_disabled` `disable_message` `is_deleted` `deleted_message`'; if (!withoutCmd) { @@ -504,7 +505,8 @@ jobs: out.push('- **Port:** ' + (record.port != null ? '`' + record.port + '`' : '—')); out.push('- **Updateable:** ' + (record.updateable ? 'Yes' : 'No')); out.push('- **Privileged:** ' + (record.privileged ? 'Yes' : 'No')); - out.push('- **ARM:** ' + (record.has_arm ? '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 : '')); @@ -838,7 +840,7 @@ jobs: const fieldName = setMatch[1].toLowerCase(); const SET_ALLOWED = { name: 'string', description: 'string', logo: 'string', - documentation: 'string', website: 'string', project_url: 'string', github: 'string', + documentation: 'string', website: 'string', project_url: 'string', repository: 'string', config_path: 'string', disable_message: 'string', deleted_message: 'string' }; if (!SET_ALLOWED[fieldName]) { @@ -888,7 +890,7 @@ jobs: documentation: 'string', website: 'string', project_url: 'string', - github: 'string', + repository: 'string', config_path: 'string', tags: 'string', port: 'number', @@ -897,7 +899,8 @@ jobs: unprivileged: 'number', updateable: 'boolean', privileged: 'boolean', - has_arm: 'boolean', + architectures: 'select_list', + platforms: 'select_list', is_dev: 'boolean', is_disabled: 'boolean', disable_message: 'string', @@ -924,6 +927,10 @@ jobs: } // Cast values to correct types + const SELECT_VALUES = { + architectures: ['amd64', 'arm64'], + platforms: ['pve', 'incus'], + }; const payload = {}; for (const [key, rawVal] of Object.entries(parsedFields)) { const type = ALLOWED_FIELDS[key]; @@ -943,6 +950,18 @@ jobs: 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 === 'nullable_string') { payload[key] = rawVal === '' ? null : rawVal; } else { diff --git a/.github/workflows/push-json-to-pocketbase.yml b/.github/workflows/push-json-to-pocketbase.yml index 9a58adf90..23d0f4f2e 100644 --- a/.github/workflows/push-json-to-pocketbase.yml +++ b/.github/workflows/push-json-to-pocketbase.yml @@ -179,7 +179,19 @@ jobs: 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.has_arm !== undefined) payload.has_arm = data.has_arm === true || data.has_arm === 'true'; + // architectures replaces has_arm: a boolean could say "also ARM" but + // not "ARM only" or "amd64 only". A record written before the field + // existed is read through the old key so nothing silently drops. + if (Array.isArray(data.architectures)) { + payload.architectures = data.architectures.filter(function (a) { return a === 'amd64' || a === 'arm64'; }); + } else if (data.has_arm !== undefined) { + payload.architectures = (data.has_arm === true || data.has_arm === 'true') ? ['amd64', 'arm64'] : ['amd64']; + } + if (Array.isArray(data.platforms)) { + payload.platforms = data.platforms.filter(function (p) { return p === 'pve' || p === 'incus'; }); + } + if (Array.isArray(data.app_vars)) payload.app_vars = data.app_vars; + if (data.repository !== undefined) payload.repository = data.repository; if (data.version !== undefined) payload.version = data.version; if (data.changelog !== undefined) payload.changelog = data.changelog; if (data.screenshots !== undefined) payload.screenshots = data.screenshots; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6493ff4f6..a84ae9cc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,6 +103,55 @@ Key rules at a glance: - Quote all variables: `"$VAR"` not `$VAR` - Use lowercase variable names - Do not hardcode credentials or sensitive values +- Never prompt without an escape hatch — see below + +### Answering a prompt up front + +An install script that only asks cannot be deployed unattended. Read the +variable first and prompt only when it is unset: + +```bash +if [[ -z "${var_admin_user:-}" ]]; then + read -r -p "${TAB3}Admin username: " var_admin_user +fi +var_admin_user="${var_admin_user:-admin}" +``` + +Name them `var_`, the same namespace the container variables use. +`install/forgejo-runner-install.sh`, `install/pangolin-install.sh`, and `install/docker-install.sh` all +follow this. + +The variable also has to be exported from `ct/.sh`, or it never reaches +the container — `lxc-attach` carries the caller's environment, but only for +what was actually exported: + +```bash +export var_admin_user="${var_admin_user:-}" +``` + +Declare them on the script's PocketBase record in `app_vars` so the website's +generator can offer them as fields: + +```json +"app_vars": [ + { + "name": "var_admin_user", + "label": "Admin Username", + "type": "text", + "default": "admin" + }, + { + "name": "var_admin_pass", + "label": "Admin Password", + "type": "password", + "secret": true + } +] +``` + +`type` is one of `text`, `password`, `number`, `boolean` (`yes`/`no`) or +`select` (with `options`). Mark anything credential-like `secret` — the +generator keeps those out of shareable links and out of the on-screen summary. Full standards and examples: **[community-scripts.org/docs/contribution](https://community-scripts.org/docs/contribution)** diff --git a/ct/forgejo-runner.sh b/ct/forgejo-runner.sh index 7c7c1774f..1184c22db 100644 --- a/ct/forgejo-runner.sh +++ b/ct/forgejo-runner.sh @@ -18,6 +18,7 @@ var_nesting="${var_nesting:-1}" var_keyctl="${var_keyctl:-1}" export var_forgejo_instance="${var_forgejo_instance:-}" +export var_forgejo_runner_uuid="${var_forgejo_runner_uuid:-}" export var_forgejo_runner_token="${var_forgejo_runner_token:-}" export var_runner_labels="${var_runner_labels:-}" @@ -64,6 +65,10 @@ if [[ -n "${mode:-}" ]]; then msg_error "var_forgejo_instance is required for unattended installs." exit 1 fi + if [[ -z "${var_forgejo_runner_uuid:-}" ]]; then + msg_error "var_forgejo_runner_uuid is required for unattended installs." + exit 1 + fi if [[ -z "${var_forgejo_runner_token:-}" ]]; then msg_error "var_forgejo_runner_token is required for unattended installs." exit 1 diff --git a/ct/pangolin.sh b/ct/pangolin.sh index 542c9c510..b69584e52 100644 --- a/ct/pangolin.sh +++ b/ct/pangolin.sh @@ -17,6 +17,9 @@ var_arm64="${var_arm64:-yes}" var_unprivileged="${var_unprivileged:-1}" var_tun="${var_tun:-1}" +export var_pangolin_url="${var_pangolin_url:-}" +export var_pangolin_email="${var_pangolin_email:-}" + header_info "$APP" variables color diff --git a/install/pangolin-install.sh b/install/pangolin-install.sh index 50b662851..ae8582f2a 100644 --- a/install/pangolin-install.sh +++ b/install/pangolin-install.sh @@ -27,9 +27,18 @@ fetch_and_deploy_gh_release "pangolin" "fosrl/pangolin" "tarball" "$PANGOLIN_VER fetch_and_deploy_gh_release "gerbil" "fosrl/gerbil" "singlefile" "latest" "/usr/bin" "gerbil_linux_$(arch_resolve)" fetch_and_deploy_gh_release "traefik" "traefik/traefik" "prebuild" "latest" "/usr/bin" "traefik_v*_linux_$(arch_resolve).tar.gz" -read -rp "${TAB3}Enter your Pangolin URL (ex: https://pangolin.example.com): " pango_url +# Read the variable first and prompt only when it is unset, so the install can +# be supplied up front. Same convention as install/forgejo-runner-install.sh. +pango_url="${var_pangolin_url:-}" +if [[ -z "$pango_url" ]]; then + read -rp "${TAB3}Enter your Pangolin URL (ex: https://pangolin.example.com): " pango_url +fi [[ "$pango_url" != https://* && "$pango_url" != http://* ]] && pango_url="https://${pango_url}" -read -rp "${TAB3}Enter your email address: " pango_email + +pango_email="${var_pangolin_email:-}" +if [[ -z "$pango_email" ]]; then + read -rp "${TAB3}Enter your email address: " pango_email +fi msg_info "Setup Pangolin" SECRET_KEY=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)