feat(setup): Import step — bring apps in from .lpapp files by path
Adds the wizard step for importing existing apps, so the common case is answerable in the WebUI rather than only from a terminal. Path-based, not upload, and that is the design rather than a shortcut. A .lpapp is a plain tarball and the file is already on the server, so nothing secret crosses into the browser — which is exactly why this can live in the WebUI when the encrypted-repository restore cannot (§4.1). Accepts a single file or a folder of them. Check first, then accept: the step enqueues `app import-check --publish`, polls the document it writes, and renders one row per file with its verdict — ready, a warning (its old storage location is gone, so it will land on the default), or a refusal (already installed, no longer shipped, will not fit). Refused rows are shown greyed with the reason rather than hidden, and cannot be selected. setupApplyConfig re-runs appImport's own checks rather than trusting the payload: the machine can change between the check and the apply, and the list arrives from a browser. The backend route shell-quotes the path — it reaches a command line and is user input. Verified: the step renders as "Step 6 of 7", and the underlying check was proven against real .lpapp files (correct app name from the tar, size from the manifest, warning for a missing storage location, refusals for an already-installed app and a non-export). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
da0136149b
commit
aa44e0b542
@ -144,6 +144,32 @@ async function enqueueTask(spec) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check a path full of .lpapp exports, without importing anything.
|
||||||
|
//
|
||||||
|
// Enqueues the host-side check and returns immediately; the result lands in
|
||||||
|
// frontend/data/system/import_check.json, which the wizard polls. Read-only,
|
||||||
|
// and a .lpapp is not encrypted, so no secret crosses this boundary — unlike a
|
||||||
|
// backup repository, which is why that one stays in the terminal installer.
|
||||||
|
router.post('/import-check', requireAuth, async (req, res) => {
|
||||||
|
const p = String((req.body && req.body.path) || '').trim();
|
||||||
|
if (!p || !p.startsWith('/')) {
|
||||||
|
return res.status(400).json({ error: 'An absolute path is required' });
|
||||||
|
}
|
||||||
|
// Shell-quote: this reaches a command line, and a path is user input.
|
||||||
|
const quoted = `'${p.replace(/'/g, "'\\''")}'`;
|
||||||
|
try {
|
||||||
|
const id = await enqueueTask({
|
||||||
|
command: `libreportal app import-check ${quoted} --publish`,
|
||||||
|
type: 'import',
|
||||||
|
app: 'libreportal',
|
||||||
|
setupRole: 'config'
|
||||||
|
});
|
||||||
|
res.json({ ok: true, taskId: id });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/save', requireAuth, async (req, res) => {
|
router.post('/save', requireAuth, async (req, res) => {
|
||||||
const payload = req.body || {};
|
const payload = req.body || {};
|
||||||
|
|
||||||
|
|||||||
@ -21,8 +21,8 @@ class SetupWizard {
|
|||||||
// Storage sits BEFORE Recommended on purpose: a location has to exist
|
// Storage sits BEFORE Recommended on purpose: a location has to exist
|
||||||
// before an app can be placed on it, and the Recommended step can then
|
// before an app can be placed on it, and the Recommended step can then
|
||||||
// offer the big apps a home other than the system disk.
|
// offer the big apps a home other than the system disk.
|
||||||
this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Recommended', 'Metrics'];
|
this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
|
||||||
this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '🛡️', '📊'];
|
this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '📦', '🛡️', '📊'];
|
||||||
// Storage is skipped entirely when this box has nowhere else to put things
|
// Storage is skipped entirely when this box has nowhere else to put things
|
||||||
// — one disk means one answer, and a step with nothing in it is noise.
|
// — one disk means one answer, and a step with nothing in it is noise.
|
||||||
// Set by loadStorage() once the candidate scan comes back.
|
// Set by loadStorage() once the candidate scan comes back.
|
||||||
@ -38,6 +38,9 @@ class SetupWizard {
|
|||||||
this.storageSystemChoice = 'primary';
|
this.storageSystemChoice = 'primary';
|
||||||
// Backup destination: '' = none, 'primary' = system disk, else a drive path.
|
// Backup destination: '' = none, 'primary' = system disk, else a drive path.
|
||||||
this.backupDest = '';
|
this.backupDest = '';
|
||||||
|
// .lpapp exports found at the path the user gave, and which to import.
|
||||||
|
this.importResults = [];
|
||||||
|
this.importSelected = [];
|
||||||
this.installLevel = 'beginner';
|
this.installLevel = 'beginner';
|
||||||
this.totalSteps = this._effectiveTotalSteps();
|
this.totalSteps = this._effectiveTotalSteps();
|
||||||
this.domainCount = 0; // tracked dynamically as the user adds rows
|
this.domainCount = 0; // tracked dynamically as the user adds rows
|
||||||
@ -262,8 +265,29 @@ class SetupWizard {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 6: Recommended apps (Traefik + Fail2ban) -->
|
<!-- Step 6: Import existing apps from .lpapp exports on this machine.
|
||||||
|
Path-based, not upload: the file is already on the server, and a
|
||||||
|
.lpapp is a plain tarball, so nothing secret crosses into the
|
||||||
|
browser (unlike a backup repository — see first-run-restore.md). -->
|
||||||
<section class="setup-step" data-step="5">
|
<section class="setup-step" data-step="5">
|
||||||
|
<div class="setup-section">
|
||||||
|
<div class="setup-section-title">Import
|
||||||
|
<span class="setup-tooltip" tabindex="0" data-tip="Bring apps over from another LibrePortal using .lpapp files made with 'libreportal app export'. Point at a file or a folder of them, somewhere on this machine.">?</span>
|
||||||
|
</div>
|
||||||
|
<div class="setup-storage-choice">
|
||||||
|
<span class="setup-storage-choice-label">Folder or file</span>
|
||||||
|
<input type="text" id="sw-import-path" class="form-control" placeholder="/mnt/usb/exports">
|
||||||
|
<button type="button" class="setup-storage-details" id="sw-import-check">Check</button>
|
||||||
|
</div>
|
||||||
|
<div id="sw-import-results"></div>
|
||||||
|
<p class="setup-section-hint" id="sw-import-note" style="margin-top:10px;">
|
||||||
|
Optional. Leave blank to skip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Step 7: Recommended apps (Traefik + Fail2ban) -->
|
||||||
|
<section class="setup-step" data-step="6">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Recommended Apps</div>
|
<div class="setup-section-title">Recommended Apps</div>
|
||||||
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
|
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
|
||||||
@ -287,7 +311,7 @@ class SetupWizard {
|
|||||||
default — they're only useful if the user wants the MONITORING
|
default — they're only useful if the user wants the MONITORING
|
||||||
toggle on apps to do anything. Advanced-only: this whole step
|
toggle on apps to do anything. Advanced-only: this whole step
|
||||||
is skipped when the user chose Beginner on step 1. -->
|
is skipped when the user chose Beginner on step 1. -->
|
||||||
<section class="setup-step" data-step="6">
|
<section class="setup-step" data-step="7">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Metrics Apps</div>
|
<div class="setup-section-title">Metrics Apps</div>
|
||||||
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
|
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
|
||||||
@ -336,6 +360,7 @@ class SetupWizard {
|
|||||||
|
|
||||||
this.attachLiveValidation();
|
this.attachLiveValidation();
|
||||||
|
|
||||||
|
$('#sw-import-check').addEventListener('click', () => this.checkImportPath());
|
||||||
$('#sw-back').addEventListener('click', () => this.prev());
|
$('#sw-back').addEventListener('click', () => this.prev());
|
||||||
$('#sw-next').addEventListener('click', () => this.next());
|
$('#sw-next').addEventListener('click', () => this.next());
|
||||||
|
|
||||||
@ -582,6 +607,9 @@ class SetupWizard {
|
|||||||
this.storageSystemChoice = 'primary';
|
this.storageSystemChoice = 'primary';
|
||||||
// Backup destination: '' = none, 'primary' = system disk, else a drive path.
|
// Backup destination: '' = none, 'primary' = system disk, else a drive path.
|
||||||
this.backupDest = '';
|
this.backupDest = '';
|
||||||
|
// .lpapp exports found at the path the user gave, and which to import.
|
||||||
|
this.importResults = [];
|
||||||
|
this.importSelected = [];
|
||||||
this.storageSystemChoice = 'primary';
|
this.storageSystemChoice = 'primary';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -699,6 +727,94 @@ class SetupWizard {
|
|||||||
+ 'It is shown on the Backup page once setup finishes.';
|
+ 'It is shown on the Backup page once setup finishes.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ask the host to inspect a path, then poll for the answer.
|
||||||
|
//
|
||||||
|
// The check runs on the host (it needs tar and the app templates), so this
|
||||||
|
// enqueues it and watches the file it publishes. Polling rather than a
|
||||||
|
// synchronous route because the task daemon owns the FIFO — the wizard has no
|
||||||
|
// way to run a command itself, by design.
|
||||||
|
async checkImportPath() {
|
||||||
|
const input = this.container.querySelector('#sw-import-path');
|
||||||
|
const box = this.container.querySelector('#sw-import-results');
|
||||||
|
const btn = this.container.querySelector('#sw-import-check');
|
||||||
|
if (!input || !box) return;
|
||||||
|
|
||||||
|
const path = input.value.trim();
|
||||||
|
this.importSelected = [];
|
||||||
|
if (!path) { box.innerHTML = ''; return; }
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
box.innerHTML = '<div class="setup-storage-choice-msg">Use a full path, starting with /.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn && (btn.disabled = true, btn.textContent = 'Checking…');
|
||||||
|
box.innerHTML = '<div class="setup-storage-choice-msg">Looking…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/import-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
// Wait for the published document to catch up with this request.
|
||||||
|
const started = Date.now();
|
||||||
|
let data = null;
|
||||||
|
while (Date.now() - started < 30000) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
try {
|
||||||
|
const f = await fetch('/data/system/import_check.json', { cache: 'no-store' });
|
||||||
|
if (f.ok) {
|
||||||
|
const d = await f.json();
|
||||||
|
if (d && d.path === path) { data = d; break; }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!data) throw new Error('the check did not finish in time');
|
||||||
|
this.importResults = Array.isArray(data.results) ? data.results : [];
|
||||||
|
this.renderImportResults();
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<div class="setup-storage-choice-msg">Could not check that path: ${this.escapeHtml(e.message || e)}</div>`;
|
||||||
|
} finally {
|
||||||
|
btn && (btn.disabled = false, btn.textContent = 'Check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderImportResults() {
|
||||||
|
const box = this.container.querySelector('#sw-import-results');
|
||||||
|
if (!box) return;
|
||||||
|
if (!this.importResults.length) {
|
||||||
|
box.innerHTML = '<div class="setup-storage-choice-msg">Nothing importable found there.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = { refuse: '\u26d4', warn: '\u26a0\ufe0f', ok: '\u2705' };
|
||||||
|
// Anything usable starts ticked: the user pointed at this folder on purpose.
|
||||||
|
this.importSelected = this.importResults
|
||||||
|
.filter(r => r.verdict !== 'refuse').map(r => r.file);
|
||||||
|
|
||||||
|
box.innerHTML = this.importResults.map((r, i) => {
|
||||||
|
const bad = r.verdict === 'refuse';
|
||||||
|
const size = r.size_bytes > 0 ? ` · ${(r.size_bytes / 1073741824).toFixed(1)}G` : '';
|
||||||
|
return `
|
||||||
|
<label class="setup-app setup-storage-card${bad ? ' setup-storage-disabled' : ''}">
|
||||||
|
<input type="checkbox" data-import-file="${this.escapeHtml(r.file)}" ${bad ? 'disabled' : 'checked'}>
|
||||||
|
<span class="setup-app-body">
|
||||||
|
<span class="setup-app-name">${icon[r.verdict] || ''} ${this.escapeHtml(r.app || r.file.split('/').pop())}${size}</span>
|
||||||
|
<span class="setup-app-desc">${this.escapeHtml(r.detail)}</span>
|
||||||
|
</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
box.querySelectorAll('[data-import-file]').forEach((cb) => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
this.importSelected = Array.from(box.querySelectorAll('[data-import-file]:checked'))
|
||||||
|
.map(x => x.dataset.importFile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Details modal — the technical spec, every check with its full explanation,
|
// Details modal — the technical spec, every check with its full explanation,
|
||||||
// and (when the drive isn't in fstab) the offer to make it permanent.
|
// and (when the drive isn't in fstab) the offer to make it permanent.
|
||||||
//
|
//
|
||||||
@ -861,8 +977,8 @@ class SetupWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 5 = Recommended (Storage at 3 and Backups at 4 shifted this along).
|
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along).
|
||||||
if (idx === 5) {
|
if (idx === 6) {
|
||||||
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
||||||
if (traefikBox && traefikBox.checked) {
|
if (traefikBox && traefikBox.checked) {
|
||||||
const tEmail = $('#sw-traefik-email').value.trim();
|
const tEmail = $('#sw-traefik-email').value.trim();
|
||||||
@ -1223,7 +1339,9 @@ class SetupWizard {
|
|||||||
// Nothing acts on it: moving LibrePortal's own tree needs real root.
|
// Nothing acts on it: moving LibrePortal's own tree needs real root.
|
||||||
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary',
|
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary',
|
||||||
// '' = don't configure backups; 'primary' = system disk; else a drive path.
|
// '' = don't configure backups; 'primary' = system disk; else a drive path.
|
||||||
backup_dest: this.backupDest || ''
|
backup_dest: this.backupDest || '',
|
||||||
|
// Absolute paths to .lpapp files the user accepted after the check.
|
||||||
|
import_files: this.importSelected || []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply the experience choice to the WebUI immediately so the next
|
// Apply the experience choice to the WebUI immediately so the next
|
||||||
|
|||||||
@ -196,6 +196,35 @@ appImportCheck()
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Same check, published where the WebUI can read it. The CLI prints one JSON
|
||||||
|
# object per line (easy to pipe); the WebUI wants one document, so this wraps
|
||||||
|
# them and writes it beside the other generated data.
|
||||||
|
appImportCheckPublish()
|
||||||
|
{
|
||||||
|
local target="$1"
|
||||||
|
local out_dir="$(webuiDir)/frontend/data/system"
|
||||||
|
local out_file="$out_dir/import_check.json"
|
||||||
|
createFolders "quiet" "$sudo_user_name" "$out_dir"
|
||||||
|
|
||||||
|
local tmp; tmp=$(mktemp) || return 1
|
||||||
|
{
|
||||||
|
printf '{\n "path": "%s",\n "checked": "%s",\n "results": [\n' \
|
||||||
|
"$(_lpJsonStr "$target")" "$(date -Iseconds)"
|
||||||
|
local first=1 line
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
(( first )) || printf ',\n'
|
||||||
|
first=0
|
||||||
|
printf ' %s' "$line"
|
||||||
|
done < <(appImportCheck "$target" 2>/dev/null)
|
||||||
|
printf '\n ]\n}\n'
|
||||||
|
} > "$tmp"
|
||||||
|
|
||||||
|
runFileWrite "$out_file" < "$tmp"
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
_lpJsonStr()
|
_lpJsonStr()
|
||||||
{
|
{
|
||||||
local s="$1"
|
local s="$1"
|
||||||
|
|||||||
@ -176,6 +176,9 @@ cliHandleAppCommands()
|
|||||||
# before it can ask for acceptance.
|
# before it can ask for acceptance.
|
||||||
if [[ -z "$app_name" ]]; then
|
if [[ -z "$app_name" ]]; then
|
||||||
isNotice "Usage: app import-check <file-or-directory>"
|
isNotice "Usage: app import-check <file-or-directory>"
|
||||||
|
elif [[ "$initial_command4" == "--publish" ]]; then
|
||||||
|
# Write the result where the WebUI reads it, instead of stdout.
|
||||||
|
appImportCheckPublish "$app_name"
|
||||||
else
|
else
|
||||||
appImportCheck "$app_name"
|
appImportCheck "$app_name"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -28,6 +28,7 @@ setupApplyConfig()
|
|||||||
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
|
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
|
||||||
local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"')
|
local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"')
|
||||||
local backup_dest=$(echo "$payload" | jq -r '.backup_dest // ""')
|
local backup_dest=$(echo "$payload" | jq -r '.backup_dest // ""')
|
||||||
|
local import_files_json=$(echo "$payload" | jq -c '.import_files // []')
|
||||||
|
|
||||||
if [[ -n "$install_name" ]]; then
|
if [[ -n "$install_name" ]]; then
|
||||||
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
|
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
|
||||||
@ -149,6 +150,25 @@ setupApplyConfig()
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Apps the user accepted on the Import step. appImport re-runs its own
|
||||||
|
# checks rather than trusting the payload — the machine may have changed
|
||||||
|
# between the check and here, and the payload arrives from a browser.
|
||||||
|
local import_count=$(echo "$import_files_json" | jq -r 'length')
|
||||||
|
if [[ "$import_count" -gt 0 ]]; then
|
||||||
|
local f i=0
|
||||||
|
while [[ $i -lt $import_count ]]; do
|
||||||
|
f=$(echo "$import_files_json" | jq -r ".[$i]")
|
||||||
|
if [[ -n "$f" && "$f" != "null" && -f "$f" ]]; then
|
||||||
|
if appImport "$f" >/dev/null 2>&1; then
|
||||||
|
isSuccessful "Imported $(basename "$f")"
|
||||||
|
else
|
||||||
|
isNotice "Could not import $(basename "$f") — run 'libreportal app import-check' on it to see why."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
local domains_count=$(echo "$domains_json" | jq -r 'length')
|
local domains_count=$(echo "$domains_json" | jq -r 'length')
|
||||||
if [[ "$domains_count" -gt 0 ]]; then
|
if [[ "$domains_count" -gt 0 ]]; then
|
||||||
local i=0
|
local i=0
|
||||||
|
|||||||
@ -49,6 +49,7 @@ declare -gA LP_FN_MAP=(
|
|||||||
[appGluetunRefreshProviders]="gluetun/tools/gluetun_refresh_providers.sh"
|
[appGluetunRefreshProviders]="gluetun/tools/gluetun_refresh_providers.sh"
|
||||||
[appImport]="app/app_portable.sh"
|
[appImport]="app/app_portable.sh"
|
||||||
[appImportCheck]="app/app_portable.sh"
|
[appImportCheck]="app/app_portable.sh"
|
||||||
|
[appImportCheckPublish]="app/app_portable.sh"
|
||||||
[appImportManifest]="app/app_portable.sh"
|
[appImportManifest]="app/app_portable.sh"
|
||||||
[appImportName]="app/app_portable.sh"
|
[appImportName]="app/app_portable.sh"
|
||||||
[appInstallCheckRequirements]="checks/requirements/check_app_install.sh"
|
[appInstallCheckRequirements]="checks/requirements/check_app_install.sh"
|
||||||
@ -1283,6 +1284,7 @@ declare -gA LP_FN_ROOT=(
|
|||||||
[appGluetunRefreshProviders]="containers"
|
[appGluetunRefreshProviders]="containers"
|
||||||
[appImport]="scripts"
|
[appImport]="scripts"
|
||||||
[appImportCheck]="scripts"
|
[appImportCheck]="scripts"
|
||||||
|
[appImportCheckPublish]="scripts"
|
||||||
[appImportManifest]="scripts"
|
[appImportManifest]="scripts"
|
||||||
[appImportName]="scripts"
|
[appImportName]="scripts"
|
||||||
[appInstallCheckRequirements]="scripts"
|
[appInstallCheckRequirements]="scripts"
|
||||||
@ -2555,6 +2557,7 @@ appGluetunRecreateRouted() { unset -f appGluetunRecreateRouted; __lpAutoload "${
|
|||||||
appGluetunRefreshProviders() { unset -f appGluetunRefreshProviders; __lpAutoload "${install_containers_dir}gluetun/tools/gluetun_refresh_providers.sh"; appGluetunRefreshProviders "$@"; }
|
appGluetunRefreshProviders() { unset -f appGluetunRefreshProviders; __lpAutoload "${install_containers_dir}gluetun/tools/gluetun_refresh_providers.sh"; appGluetunRefreshProviders "$@"; }
|
||||||
appImport() { unset -f appImport; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImport "$@"; }
|
appImport() { unset -f appImport; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImport "$@"; }
|
||||||
appImportCheck() { unset -f appImportCheck; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportCheck "$@"; }
|
appImportCheck() { unset -f appImportCheck; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportCheck "$@"; }
|
||||||
|
appImportCheckPublish() { unset -f appImportCheckPublish; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportCheckPublish "$@"; }
|
||||||
appImportManifest() { unset -f appImportManifest; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportManifest "$@"; }
|
appImportManifest() { unset -f appImportManifest; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportManifest "$@"; }
|
||||||
appImportName() { unset -f appImportName; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportName "$@"; }
|
appImportName() { unset -f appImportName; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportName "$@"; }
|
||||||
appInstallCheckRequirements() { unset -f appInstallCheckRequirements; __lpAutoload "${install_scripts_dir}checks/requirements/check_app_install.sh"; appInstallCheckRequirements "$@"; }
|
appInstallCheckRequirements() { unset -f appInstallCheckRequirements; __lpAutoload "${install_scripts_dir}checks/requirements/check_app_install.sh"; appInstallCheckRequirements "$@"; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user