Move Custom Scripts out of the repo and add an Import Script picker

Custom Scripts (the Scheduler page's inline editor) used to save into the
git-tracked Custom/ folder, so anything saved there would end up on GitHub.
They now live in /boot/config/plugins/user.scripts/Varaverk/Scripts, same
folder family as Unraid's own User Scripts plugin. Import Script lets you
browse the whole server and move an existing script in instead of only
creating new ones inline — always a move, never a copy, so no stray
duplicate is left where it came from.
This commit is contained in:
Gmer4Lfe
2026-07-03 10:57:35 -04:00
parent 93aa134aaa
commit 6fd22ae4ee
7 changed files with 352 additions and 10 deletions
+25 -1
View File
@@ -305,7 +305,31 @@ entries needed. The plugin handles all triggers natively:
- **Cron** → `Plugin/unraid/event/disks_mounted/rebuild_cron` rebuilds the cron file from `schedule.json` on every boot
Configure via the Varaverk plugin Scheduler tab (or edit `schedule.json` directly).
Individual scripts are never scheduled — only orchestrators.
The built-in job list schedules orchestrators, never individual repo scripts directly —
but the Scheduler tab's **Custom Scripts** card is the one place individual scripts
*are* scheduled directly (see below).
---
### ── Custom Scripts ────────────────────────────────────────────────────────────
The Scheduler tab has a **Custom Scripts** card for one-off scripts that aren't part of
the repo's orchestrator pipeline — personal tooling, quick fixes, anything you don't
want to wire into `master.conf`.
Scripts live in `/boot/config/plugins/user.scripts/Varaverk/Scripts/` — deliberately
**outside** the Varaverk git repo (that folder is never pushed to GitHub), in the same
place the Unraid User Scripts plugin keeps its own scripts, so it's a folder location
admins are already used to.
Two ways to get a script there:
- Click **+ Create Script** on the Scheduler tab — opens an inline editor, writes the
file to that folder, and adds a `schedule.json` entry automatically.
- Drop any `.sh` file into the folder yourself (e.g. via terminal, or Unraid's own
Custom Scripts / User Scripts plugin pointed at the same path). The Scheduler tab
**auto-detects** it — discovery is a folder scan, not a registry, so it doesn't matter
how the file got there. It shows up disabled with no cron until you configure one.
---
+14 -2
View File
@@ -73,8 +73,8 @@ to Community Applications.
## ━━━ SCRIPTS DIRECTORY SETTING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`SCRIPTS_DIR` is the only plugin-level setting. It tells the plugin where to find the
Configurations directory and all scripts.
`SCRIPTS_DIR` tells the plugin where to find the Configurations directory and all
repo scripts.
**Set it via:** Settings → Other Settings → Varaverk → Scripts directory
@@ -88,6 +88,18 @@ SCRIPTS_DIR="/boot/config/plugins/varaverk"
All other configuration lives in `Configurations/master.conf` and `Configurations/host*.conf`.
`CUSTOM_SCRIPTS_DIR` is a separate, optional override for where the Scheduler tab's
Custom Scripts feature reads/writes user-authored scripts (see Manual.md → Custom
Scripts). It's intentionally **not** under `SCRIPTS_DIR` — Custom Scripts are personal,
non-repo tooling and must never end up inside the git-tracked plugin folder.
Default: `/boot/config/plugins/user.scripts/Varaverk/Scripts`
```bash
# in varaverk.cfg, alongside SCRIPTS_DIR
CUSTOM_SCRIPTS_DIR="/boot/config/plugins/user.scripts/Varaverk/Scripts"
```
---
## ━━━ REPO MOVE PROCEDURE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+105
View File
@@ -0,0 +1,105 @@
<?php
// Import Script — lets the Scheduler page's "+ Import Script" browser move an existing
// .sh file from anywhere on the server into CUSTOM_SCRIPTS_DIR. This is a MOVE: the
// source is deleted once the copy is verified, so no stale duplicate is left behind.
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
// ── browse (GET): list subdirectories and .sh files at $path, rooted at / ─────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && ($_GET['action'] ?? '') === 'browse') {
$path = trim($_GET['path'] ?? '/');
if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid path']);
exit;
}
$clean = rtrim($path, '/') ?: '/';
if (!is_dir($clean)) {
echo json_encode(['ok' => false, 'error' => 'Not a directory: ' . $clean]);
exit;
}
$dirOut = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -300') ?: '';
$dirs = array_values(array_filter(array_map('trim', explode("\n", $dirOut))));
$fileOut = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type f -iname "*.sh" 2>/dev/null | sort | head -300') ?: '';
$files = array_values(array_filter(array_map('trim', explode("\n", $fileOut))));
$parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'files' => $files, 'parent' => $parent]);
exit;
}
// ── import (POST): move the chosen .sh file into CUSTOM_SCRIPTS_DIR ───────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'import') {
$src = trim($_POST['path'] ?? '');
if (!preg_match('#^/[^\0]*\.sh$#i', $src) || str_contains($src, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid script path']);
exit;
}
if (!is_file($src)) {
echo json_encode(['ok' => false, 'error' => 'Not found: ' . $src]);
exit;
}
$srcReal = realpath($src);
if ($srcReal === false) {
echo json_encode(['ok' => false, 'error' => 'Could not resolve path']);
exit;
}
// Refuse to move a file out of the git-tracked repo — that would delete a
// tracked file out from under git without a commit recording it.
$repoReal = realpath(SCRIPTS_DIR);
if ($repoReal && str_starts_with($srcReal, $repoReal . '/')) {
echo json_encode(['ok' => false, 'error' => 'Refusing to import from inside the Varaverk repo — that would delete a git-tracked file.']);
exit;
}
// Already there — nothing to do.
$customReal = realpath(CUSTOM_SCRIPTS_DIR) ?: CUSTOM_SCRIPTS_DIR;
if (str_starts_with($srcReal, rtrim($customReal, '/') . '/')) {
echo json_encode(['ok' => false, 'error' => 'Already in Custom Scripts.']);
exit;
}
if (!is_dir(CUSTOM_SCRIPTS_DIR)) mkdir(CUSTOM_SCRIPTS_DIR, 0755, true);
$name = basename($srcReal);
$dest = CUSTOM_SCRIPTS_DIR . '/' . $name;
if (file_exists($dest)) {
echo json_encode(['ok' => false, 'error' => "A script named \"$name\" already exists in Custom Scripts."]);
exit;
}
// Copy across filesystems, verify, THEN delete the source — never remove the
// only copy on a failed or partial copy.
if (!copy($srcReal, $dest)) {
@unlink($dest);
echo json_encode(['ok' => false, 'error' => 'Copy failed']);
exit;
}
if (filesize($srcReal) !== filesize($dest) || hash_file('sha256', $srcReal) !== hash_file('sha256', $dest)) {
@unlink($dest);
echo json_encode(['ok' => false, 'error' => 'Copy verification failed — source left untouched']);
exit;
}
chmod($dest, 0755);
if (!@unlink($srcReal)) {
// Copied and verified but couldn't remove the original (permissions, read-only
// mount). The script is usable from its new home either way — surface a warning
// rather than failing the import outright.
echo json_encode([
'ok' => true,
'id' => 'Custom/' . $name,
'warning' => 'Imported, but could not delete the original at ' . $srcReal . ' — remove it manually.',
]);
exit;
}
echo json_encode(['ok' => true, 'id' => 'Custom/' . $name]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Invalid request']);
+3 -3
View File
@@ -8,7 +8,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$path = SCRIPTS_DIR . '/' . $id;
$path = CUSTOM_SCRIPTS_DIR . '/' . substr($id, strlen('Custom/'));
echo json_encode(['ok' => true, 'content' => file_exists($path) ? file_get_contents($path) : '']);
exit;
}
@@ -24,7 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$id = 'Custom/' . $name . '.sh';
$path = SCRIPTS_DIR . '/Custom/' . $name . '.sh';
$path = CUSTOM_SCRIPTS_DIR . '/' . $name . '.sh';
if ($action === 'delete') {
if (!file_exists($path)) {
@@ -40,7 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
$dir = SCRIPTS_DIR . '/Custom';
$dir = CUSTOM_SCRIPTS_DIR;
if (!is_dir($dir)) mkdir($dir, 0755, true);
if (file_put_contents($path, $content) === false) {
echo json_encode(['ok' => false, 'error' => 'Failed to write script']);
+5
View File
@@ -11,6 +11,11 @@ define('DEPLOY_DIR', SCRIPTS_DIR . '/Deployment');
define('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
// User-authored custom scripts (scheduler page "+ Create Script") — kept outside the git
// repo entirely, alongside the User Scripts plugin's own storage. Any *.sh file placed
// directly in this folder is auto-detected and listed — it doesn't have to be created
// through the page's editor.
define('CUSTOM_SCRIPTS_DIR', $_vv_cfg['CUSTOM_SCRIPTS_DIR'] ?? '/boot/config/plugins/user.scripts/Varaverk/Scripts');
unset($_vv_cfg);
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
+9 -2
View File
@@ -97,7 +97,10 @@ function vv_cron_rebuild(array $schedule): bool {
$id = $entry['id'];
// When a child's orch is enabled it is the sole trigger — suppress independent cron.
if (isset($childToOrch[$id]) && !empty($schedule[$childToOrch[$id]]['enabled'])) continue;
$script = "$scriptsDir/$id";
// Custom Scripts live outside the repo (CUSTOM_SCRIPTS_DIR) — everything else resolves under SCRIPTS_DIR.
$script = str_starts_with($id, 'Custom/')
? CUSTOM_SCRIPTS_DIR . '/' . substr($id, strlen('Custom/'))
: "$scriptsDir/$id";
$flags = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags";
}
@@ -278,6 +281,10 @@ function vv_tools_scripts(): array {
return $scripts;
}
// Lists every Custom Script for the scheduler page. Discovery is glob-based, not a
// registry — any *.sh file dropped directly into CUSTOM_SCRIPTS_DIR (or a platform
// adapter's own Custom/ folder) shows up here, whether or not it was created via the
// page's "+ Create Script" editor or has a schedule.json entry yet.
function vv_custom_scripts(): array {
$schedule = vv_schedule_load();
$scripts = [];
@@ -297,7 +304,7 @@ function vv_custom_scripts(): array {
}
};
$collect(SCRIPTS_DIR . '/Custom', 'Custom/');
$collect(CUSTOM_SCRIPTS_DIR, 'Custom/');
// Platform adapter custom scripts (Plugin/<platform>/Custom/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Custom') ?: [] as $customDir) {
+191 -2
View File
@@ -272,7 +272,7 @@ $runningScripts = array_unique($runningScripts);
</div>
<div class="vv-children" id="vv-custom-children" style="display:none;">
<?php if (empty($customs)): ?>
<p class="vv-custom-empty">No custom scripts yet — click <strong>+ Add Script</strong> to create one.</p>
<p class="vv-custom-empty">No custom scripts yet — click <strong>+ Create Script</strong> to create one.</p>
<?php else: ?>
<?php // ── Folder groups ──────────────────────────────────────────────
@@ -362,7 +362,8 @@ $runningScripts = array_unique($runningScripts);
</div><!-- /#vv-sched-cards -->
<div class="vv-sched-footer">
<button class="vv-save-btn vv-add-script-btn" onclick="vvAddScript()">+ Add Script</button>
<button class="vv-save-btn vv-add-script-btn" onclick="vvAddScript()">+ Create Script</button>
<button class="vv-save-btn vv-import-script-btn" onclick="vvImportScriptOpen()" title="Move an existing script from anywhere on the server into Custom Scripts">+ Import Script</button>
<button class="vv-save-btn vv-new-folder-btn" onclick="vvNewFolder()" title="Create a folder in Custom Scripts">+ Folder</button>
<button id="vv-arrange-btn" class="vv-save-btn" onclick="vvToggleArrange()" title="Drag scripts between orchestrators">Arrange</button>
<button id="vv-arrange-save-btn" class="vv-save-btn vv-arrange-save-btn" onclick="vvSaveArrange()" style="display:none">Save Arrangement</button>
@@ -1044,6 +1045,9 @@ function vvLH() {
let vvSetupConf = <?= json_encode($vv_setup_conf) ?>;
const vvLocalHostConf = <?= json_encode($_vv_local_host_conf) ?>;
// Where Custom Scripts (Create + Import) actually live — shown in the Import Script dialog
window.__vvCustomScriptsDir = <?= json_encode(CUSTOM_SCRIPTS_DIR) ?>;
if (vvSetupConf) {
// Auto-open the setup conf file once the page is ready
document.addEventListener('DOMContentLoaded', () => {
@@ -1718,6 +1722,191 @@ function vvDeleteScript() {
.catch(() => { btn.disabled = false; btn.textContent = '\u{1F5D1} Delete'; });
}
// ── Import Script — browse the whole server, move a chosen .sh into Custom Scripts ──
let _vvImpSelected = null; // full path of the currently-selected file, or null
function vvImportScriptOpen() {
let modal = document.getElementById('vv-import-modal');
if (!modal) modal = _vvImpBuildModal();
modal.style.display = 'flex';
_vvImpSelected = null;
_vvImpUpdateFooter();
_vvImpBrowse('/');
}
function vvImportScriptClose() {
const modal = document.getElementById('vv-import-modal');
if (modal) modal.style.display = 'none';
_vvImpSelected = null;
}
function _vvImpBuildModal() {
const modal = document.createElement('div');
modal.id = 'vv-import-modal';
modal.style.cssText = 'display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);'
+ 'align-items:center;justify-content:center;';
modal.addEventListener('mousedown', e => { if (e.target === modal) vvImportScriptClose(); });
const box = document.createElement('div');
box.style.cssText = 'background:#111;border:1px solid #222;border-radius:6px;width:560px;max-width:92vw;'
+ 'max-height:80vh;display:flex;flex-direction:column;overflow:hidden;';
box.innerHTML = `
<div style="padding:10px 14px;border-bottom:1px solid #1e1e1e;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;font-weight:bold;color:#ccc;">Import Script</span>
<button onclick="vvImportScriptClose()" style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;">&#10005;</button>
</div>
<div style="padding:10px 14px;border-bottom:1px solid #1e1e1e;">
<div style="font-size:10px;color:#555;margin-bottom:6px;">
Moves the selected script into Custom Scripts (<code>${_vvImpEsc(window.__vvCustomScriptsDir || '')}</code>).
The original is removed once the copy is verified.
</div>
<div style="display:flex;gap:6px;align-items:center;">
<input id="vv-imp-path" type="text" value="/" style="flex:1;min-width:0;background:#0a0a0a;border:1px solid #222;
color:#aaa;border-radius:3px;padding:4px 8px;font-size:11px;font-family:monospace;"
onkeydown="if(event.key==='Enter')_vvImpBrowse(document.getElementById('vv-imp-path').value.trim()||'/')">
<button onclick="_vvImpNavUp()" title="Parent" style="background:#111;border:1px solid #222;color:#555;
border-radius:3px;padding:4px 8px;cursor:pointer;font-size:12px;flex-shrink:0;">&#8593;</button>
<button onclick="_vvImpBrowse(document.getElementById('vv-imp-path').value.trim()||'/')" style="background:#111;
border:1px solid #222;color:#4a9eff;border-radius:3px;padding:4px 9px;cursor:pointer;font-size:11px;
white-space:nowrap;flex-shrink:0;">&#8635; Browse</button>
</div>
<span id="vv-imp-err" style="font-size:9px;color:#ef5350;display:none;margin-top:4px;"></span>
</div>
<div id="vv-imp-list" style="flex:1;overflow-y:auto;background:#080808;min-height:220px;"></div>
<div style="padding:10px 14px;border-top:1px solid #1e1e1e;display:flex;align-items:center;justify-content:space-between;gap:10px;">
<span id="vv-imp-selected" style="font-size:10px;color:#555;font-family:monospace;flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
<div style="display:flex;gap:6px;flex-shrink:0;">
<button onclick="vvImportScriptClose()" class="vv-btn-sm">Cancel</button>
<button id="vv-imp-do-btn" class="vv-btn-sm vv-save-script-btn-style" disabled onclick="_vvImpDoImport()">Import</button>
</div>
</div>
`;
modal.appendChild(box);
document.body.appendChild(modal);
return modal;
}
function _vvImpEsc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _vvImpBrowse(path) {
const listEl = document.getElementById('vv-imp-list');
const errEl = document.getElementById('vv-imp-err');
const pathEl = document.getElementById('vv-imp-path');
errEl.style.display = 'none';
listEl.innerHTML = '<div style="padding:10px;color:#444;font-size:11px;">Loading…</div>';
fetch(`/plugins/varaverk/api/import_script.php?action=browse&path=${encodeURIComponent(path)}&_=${Date.now()}`)
.then(r => r.json())
.then(d => {
if (!d.ok) {
listEl.innerHTML = '';
errEl.textContent = d.error || 'Browse failed';
errEl.style.display = '';
return;
}
pathEl.value = d.path;
_vvImpRender(d);
})
.catch(e => {
listEl.innerHTML = '';
errEl.textContent = 'Request failed: ' + e;
errEl.style.display = '';
});
}
function _vvImpNavUp() {
const cur = (document.getElementById('vv-imp-path').value || '/').replace(/\/$/, '') || '/';
const up = cur === '/' ? '/' : (cur.substring(0, cur.lastIndexOf('/')) || '/');
_vvImpBrowse(up);
}
function _vvImpRender(d) {
const listEl = document.getElementById('vv-imp-list');
let html = '';
if (d.parent !== null) {
const pname = d.parent === '/' ? '/' : (d.parent.replace(/^.*\//, '') || d.parent) + '/';
html += `<div class="vv-imp-row" onclick="_vvImpBrowse(${JSON.stringify(d.parent)})"
style="padding:5px 12px;font-size:11px;color:#444;font-style:italic;cursor:pointer;font-family:monospace;">&#8593; ${pname}</div>`;
}
for (const dir of d.dirs) {
const name = dir.replace(/^.*\//, '') || dir;
html += `<div class="vv-imp-row" onclick="_vvImpBrowse(${JSON.stringify(dir)})" title="${_vvImpEsc(dir)}"
style="padding:5px 12px;font-size:11px;color:#666;cursor:pointer;font-family:monospace;">&#9654; ${_vvImpEsc(name)}</div>`;
}
for (const file of d.files) {
const name = file.replace(/^.*\//, '') || file;
const sel = file === _vvImpSelected;
html += `<div class="vv-imp-row vv-imp-file${sel ? ' vv-imp-file-sel' : ''}" data-path="${_vvImpEsc(file)}"
onclick="_vvImpSelectFile(${JSON.stringify(file)})" title="${_vvImpEsc(file)}"
style="padding:5px 12px;font-size:11px;cursor:pointer;font-family:monospace;
color:${sel ? '#4caf50' : '#4a9eff'};background:${sel ? '#0f1f0f' : 'transparent'};">&#128196; ${_vvImpEsc(name)}</div>`;
}
if (!d.dirs.length && !d.files.length) {
html += '<div style="padding:10px 12px;color:#333;font-size:11px;">— empty —</div>';
}
listEl.innerHTML = html;
}
function _vvImpSelectFile(path) {
_vvImpSelected = path;
_vvImpUpdateFooter();
// Re-render just the highlight without a re-fetch.
document.querySelectorAll('#vv-imp-list .vv-imp-file').forEach(el => {
const isSel = el.dataset.path === path;
el.classList.toggle('vv-imp-file-sel', isSel);
el.style.color = isSel ? '#4caf50' : '#4a9eff';
el.style.background = isSel ? '#0f1f0f' : 'transparent';
});
}
function _vvImpUpdateFooter() {
const sel = document.getElementById('vv-imp-selected');
const btn = document.getElementById('vv-imp-do-btn');
if (!sel || !btn) return;
sel.textContent = _vvImpSelected || '';
btn.disabled = !_vvImpSelected;
}
function _vvImpDoImport() {
if (!_vvImpSelected) return;
const dest = (window.__vvCustomScriptsDir || 'Custom Scripts') + '/' + _vvImpSelected.replace(/^.*\//, '');
if (!confirm('Move\n ' + _vvImpSelected + '\n→ ' + dest + '\n\nThe original will be deleted once the copy is verified. Continue?')) return;
const btn = document.getElementById('vv-imp-do-btn');
btn.disabled = true;
btn.textContent = 'Importing…';
vvPost('/plugins/varaverk/api/import_script.php', {action: 'import', path: _vvImpSelected})
.then(d => {
if (!d.ok) {
alert('Import failed: ' + (d.error || 'Unknown error'));
btn.disabled = false;
btn.textContent = 'Import';
return;
}
if (d.warning) alert(d.warning);
window.location.reload();
})
.catch(e => {
alert('Import failed: ' + e);
btn.disabled = false;
btn.textContent = 'Import';
});
}
function vvShowConfMode(title) {
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';