Merge claude/2
This commit is contained in:
commit
5ef969871e
@ -12,6 +12,11 @@
|
||||
# ADMIN_PASSWORD = password used for the Bookstack admin account
|
||||
#
|
||||
CFG_BOOKSTACK_APP_NAME=bookstack
|
||||
# MULTI_INSTANCE = if true, this app can run as multiple isolated instances
|
||||
# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on
|
||||
# apps whose compose identity (container_name, Traefik routers, backup labels)
|
||||
# is instance-safe — see scripts/instance/instance_create.sh.
|
||||
CFG_BOOKSTACK_MULTI_INSTANCE=true
|
||||
CFG_BOOKSTACK_BACKUP=true
|
||||
CFG_BOOKSTACK_BACKUP_STRATEGY=auto
|
||||
CFG_BOOKSTACK_COMPOSE_FILE=default
|
||||
|
||||
@ -435,3 +435,163 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Multi-instance — "instance of" badge + the "New instance" card action and
|
||||
creation modal. An instance is just another app; this is only the create UX.
|
||||
========================================================================= */
|
||||
.app-tag.instance-tag {
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.30);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-card-actions .new-instance-btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px dashed rgba(var(--accent-rgb), 0.55) !important;
|
||||
transition: background 0.2s, border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
.app-card-actions .new-instance-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.lp-instance-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
}
|
||||
.lp-instance-modal {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border, var(--border-color));
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||
padding: 22px 22px 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lp-instance-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.lp-instance-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lp-instance-x {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.lp-instance-x:hover { color: var(--text-primary); }
|
||||
.lp-instance-sub {
|
||||
margin: 6px 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.lp-instance-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.lp-instance-field > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.lp-instance-field input,
|
||||
.lp-instance-field select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.lp-instance-field input:focus,
|
||||
.lp-instance-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.lp-instance-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-height: 14px;
|
||||
}
|
||||
.lp-instance-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.lp-instance-row .lp-instance-field { flex: 1; }
|
||||
.lp-instance-preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 2px 0 18px;
|
||||
}
|
||||
.lp-instance-preview code {
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lp-instance-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.lp-instance-btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.2s, border-color 0.2s, opacity 0.2s;
|
||||
}
|
||||
.lp-instance-cancel:hover { background: rgba(var(--text-rgb), 0.08); }
|
||||
.lp-instance-create {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lp-instance-create:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lp-instance-create:not(:disabled):hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@ -209,6 +209,20 @@ Object.assign(AppsManager.prototype, {
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
// Multi-instance: capability + instance-of are read straight off the app's
|
||||
// own config (apps.json emits every CFG_<SLUG>_* var). An instance is just
|
||||
// another app, so this only adds a sibling badge + a "New instance" action.
|
||||
const _cfg = app.config || {};
|
||||
const _U = appName.toUpperCase();
|
||||
const instanceOf = _cfg[`CFG_${_U}_INSTANCE_OF`] || '';
|
||||
const isMultiCapable = String(_cfg[`CFG_${_U}_MULTI_INSTANCE`]).toLowerCase() === 'true';
|
||||
const instanceBadge = instanceOf
|
||||
? `<span class="app-tag instance-tag" title="An isolated instance of ${instanceOf}">⧉ instance of ${instanceOf}</span>`
|
||||
: '';
|
||||
const newInstanceBtn = isMultiCapable
|
||||
? `<button class="new-instance-btn" title="Run another isolated copy of this app" onclick="event.stopPropagation(); window.instanceManager && window.instanceManager.openCreateModal('${appName}')">+ New instance</button>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="app-card-top" style="cursor: pointer;" onclick="appsManager.showAppDetail('${appName}')">
|
||||
<div class="app-card-icon">
|
||||
@ -219,6 +233,7 @@ Object.assign(AppsManager.prototype, {
|
||||
<div class="app-card-tags">
|
||||
${descriptionTag}
|
||||
${categoryTag}
|
||||
${instanceBadge}
|
||||
<span class="app-tag clickable ${app.installed ? 'installed-tag' : 'not-installed-tag'}" onclick="event.stopPropagation(); appsManager.switchCategory('${app.installed ? 'installed' : 'all'}')">${status}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -228,6 +243,7 @@ Object.assign(AppsManager.prototype, {
|
||||
<button class="${app.installed ? 'manage-btn' : 'install-btn'}" onclick="appsManager.showAppDetailWithConfig('${appName}')">
|
||||
${app.installed ? 'Manage' : 'Install'}
|
||||
</button>
|
||||
${newInstanceBtn}
|
||||
${serviceTrigger}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -0,0 +1,186 @@
|
||||
// Multi-instance UI — the "New instance" flow for multi-instance-capable apps.
|
||||
//
|
||||
// An instance is just another app (slug <type>_<id>), so once created it flows
|
||||
// through the existing grid / detail / config / routing / backups pages with no
|
||||
// special-casing. This file only owns the *creation* affordance: a small modal
|
||||
// that collects a name + domain + subdomain and dispatches the standard task
|
||||
// (libreportal instance create …) through the same task pipeline as install.
|
||||
//
|
||||
// Capability + instance-of are read straight off app.config (the apps.json
|
||||
// generator already emits every CFG_<SLUG>_* var), so the grid stays declarative.
|
||||
|
||||
class InstanceManager {
|
||||
constructor() {
|
||||
this.domains = [];
|
||||
}
|
||||
|
||||
// Read configured domains (CFG_DOMAIN_1..9) from the generated system config.
|
||||
async _loadDomains() {
|
||||
try {
|
||||
const r = await fetch('/data/config/generated/configs.json', { cache: 'no-store' });
|
||||
if (!r.ok) return [];
|
||||
const data = await r.json();
|
||||
const cfg = data.config || {};
|
||||
const out = [];
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const k = `CFG_DOMAIN_${i}`;
|
||||
const v = ((cfg[k] && cfg[k].value !== undefined ? cfg[k].value : cfg[k]) || '').toString().trim();
|
||||
if (v) out.push({ number: i, domain: v });
|
||||
}
|
||||
return out;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// The type's display title, for the modal heading.
|
||||
_typeTitle(typeSlug) {
|
||||
const a = (window.apps || []).find(x => (x.command || '').split(' ').pop() === typeSlug);
|
||||
return a ? (a.name || typeSlug).split(' - ')[0].trim() : typeSlug;
|
||||
}
|
||||
|
||||
// user text -> [a-z0-9] id (mirrors the backend's instanceIdPart)
|
||||
_idPart(raw) {
|
||||
return String(raw || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
_esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
|
||||
close() {
|
||||
const el = document.getElementById('lp-instance-modal');
|
||||
if (el) el.remove();
|
||||
document.removeEventListener('keydown', this._onKey);
|
||||
}
|
||||
|
||||
async openCreateModal(typeSlug) {
|
||||
if (document.getElementById('lp-instance-modal')) return;
|
||||
const title = this._typeTitle(typeSlug);
|
||||
this.domains = await this._loadDomains();
|
||||
|
||||
const domainOptions = this.domains.length
|
||||
? this.domains.map(d => `<option value="${d.number}">${this._esc(d.domain)}</option>`).join('')
|
||||
: '<option value="1">Domain 1 (set one in Admin → Config)</option>';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'lp-instance-modal';
|
||||
overlay.className = 'lp-instance-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="lp-instance-modal" role="dialog" aria-modal="true" aria-label="New ${this._esc(title)} instance">
|
||||
<div class="lp-instance-head">
|
||||
<h3>New ${this._esc(title)} instance</h3>
|
||||
<button type="button" class="lp-instance-x" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p class="lp-instance-sub">A fully isolated second copy — its own data, database, subdomain, backups and update cadence.</p>
|
||||
|
||||
<label class="lp-instance-field">
|
||||
<span>Instance name</span>
|
||||
<input type="text" id="lp-instance-name" placeholder="e.g. blog" autocomplete="off" spellcheck="false" />
|
||||
<small id="lp-instance-slug" class="lp-instance-hint"></small>
|
||||
</label>
|
||||
|
||||
<div class="lp-instance-row">
|
||||
<label class="lp-instance-field">
|
||||
<span>Domain</span>
|
||||
<select id="lp-instance-domain">${domainOptions}</select>
|
||||
</label>
|
||||
<label class="lp-instance-field">
|
||||
<span>Subdomain</span>
|
||||
<input type="text" id="lp-instance-subdomain" placeholder="auto" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="lp-instance-preview">
|
||||
Will be served at <code id="lp-instance-host">…</code>
|
||||
</div>
|
||||
|
||||
<div class="lp-instance-actions">
|
||||
<button type="button" class="lp-instance-btn lp-instance-cancel">Cancel</button>
|
||||
<button type="button" class="lp-instance-btn lp-instance-create" disabled>Create instance</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const nameEl = overlay.querySelector('#lp-instance-name');
|
||||
const slugEl = overlay.querySelector('#lp-instance-slug');
|
||||
const subEl = overlay.querySelector('#lp-instance-subdomain');
|
||||
const domEl = overlay.querySelector('#lp-instance-domain');
|
||||
const hostEl = overlay.querySelector('#lp-instance-host');
|
||||
const createBtn = overlay.querySelector('.lp-instance-create');
|
||||
|
||||
let subEdited = false;
|
||||
subEl.addEventListener('input', () => { subEdited = true; });
|
||||
|
||||
const refresh = () => {
|
||||
const id = this._idPart(nameEl.value);
|
||||
const slug = id ? `${typeSlug}_${id}` : '';
|
||||
slugEl.textContent = slug ? `Created as ${slug}` : 'Letters and numbers only';
|
||||
if (!subEdited) subEl.value = slug ? slug.replace(/_/g, '-') : '';
|
||||
const dom = (this.domains.find(d => String(d.number) === domEl.value) || {}).domain || '<your-domain>';
|
||||
const sub = (subEl.value || '').trim();
|
||||
hostEl.textContent = sub ? `${sub}.${dom}` : `…`;
|
||||
createBtn.disabled = !id;
|
||||
};
|
||||
nameEl.addEventListener('input', refresh);
|
||||
domEl.addEventListener('change', refresh);
|
||||
subEl.addEventListener('input', refresh);
|
||||
refresh();
|
||||
setTimeout(() => nameEl.focus(), 30);
|
||||
|
||||
this._onKey = (e) => { if (e.key === 'Escape') this.close(); };
|
||||
document.addEventListener('keydown', this._onKey);
|
||||
overlay.querySelector('.lp-instance-x').addEventListener('click', () => this.close());
|
||||
overlay.querySelector('.lp-instance-cancel').addEventListener('click', () => this.close());
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
|
||||
|
||||
createBtn.addEventListener('click', () => this._submit(typeSlug, nameEl.value, domEl.value, subEl.value));
|
||||
}
|
||||
|
||||
async _submit(typeSlug, name, domainIndex, subdomain) {
|
||||
const id = this._idPart(name);
|
||||
if (!id) return;
|
||||
const slug = `${typeSlug}_${id}`;
|
||||
|
||||
// Ensure the task system is up (same on-demand load path install uses).
|
||||
if ((!window.tasksManager || !window.tasksManager.router) && window.appsManager && window.appsManager.loadTaskSystem) {
|
||||
try { await window.appsManager.loadTaskSystem(); } catch (_) {}
|
||||
}
|
||||
|
||||
const notify = (msg, kind) => {
|
||||
const sys = (typeof window.ensureNotificationSystem === 'function')
|
||||
? window.ensureNotificationSystem() : window.notificationSystem;
|
||||
if (sys && typeof sys.show === 'function') sys.show(msg, kind || 'info');
|
||||
};
|
||||
|
||||
if (!window.tasksManager || !window.tasksManager.router) {
|
||||
notify('Task system unavailable — could not start instance creation.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.tasksManager.router.routeAction('instance_create', {
|
||||
type: typeSlug,
|
||||
name: name,
|
||||
domainIndex: domainIndex,
|
||||
subdomain: subdomain
|
||||
});
|
||||
this.close();
|
||||
notify(`Creating instance ${slug} — track progress in Tasks.`, 'success');
|
||||
if (window.librePortalSPA && window.librePortalSPA.navigateTo) {
|
||||
window.librePortalSPA.navigateTo('/tasks');
|
||||
} else if (window.navigateToRoute) {
|
||||
window.navigateToRoute('tasks');
|
||||
} else {
|
||||
window.location.href = '/tasks';
|
||||
}
|
||||
} catch (error) {
|
||||
notify(`Failed to create instance: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.instanceManager = window.instanceManager || new InstanceManager();
|
||||
@ -208,7 +208,8 @@ class SystemLoader {
|
||||
'/components/apps/core/js/gluetun-vpn.js',
|
||||
'/components/apps/core/js/config-dirty-guard.js',
|
||||
'/components/apps/core/js/install-console.js',
|
||||
'/components/apps/core/js/service-buttons-sidebar.js'
|
||||
'/components/apps/core/js/service-buttons-sidebar.js',
|
||||
'/components/apps/core/js/instance-manager.js' // multi-instance "New instance" modal (window.instanceManager)
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@ -28,6 +28,28 @@ class TaskActions {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a new isolated instance of a multi-instance-capable app.
|
||||
* Dispatched as an 'install' of the new instance slug so the UI treats it
|
||||
* like any install (button state, task icon, progress), while the verbatim
|
||||
* command carries the optional domain#/subdomain through executeTask.
|
||||
*/
|
||||
async instanceCreate(type, name, domainIndex = '', subdomain = '') {
|
||||
try {
|
||||
this.commands.validateCommand('instance_create', { type, name });
|
||||
const parts = ['libreportal', 'instance', 'create', type, name];
|
||||
if (domainIndex) parts.push(domainIndex);
|
||||
if (subdomain) parts.push(subdomain);
|
||||
// The new instance's slug — mirrors the backend's <type>_<id> scheme so
|
||||
// monitorTask/highlight track the right app once it appears.
|
||||
const id = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const slug = `${type}_${id}`;
|
||||
return await this.executeTask('install', slug, parts.join(' '), `New ${type} instance`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create ${type} instance "${name}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an application
|
||||
*/
|
||||
|
||||
@ -15,6 +15,11 @@ class TaskCommands {
|
||||
backup: 'libreportal app backup {appName}',
|
||||
status: 'libreportal app status {appName}',
|
||||
|
||||
// Multi-instance (✅ IMPLEMENTED) — provision another isolated copy of a
|
||||
// multi-instance-capable app. The verbatim command (with optional
|
||||
// domain#/subdomain) is built in TaskActions.instanceCreate.
|
||||
instance_create: 'libreportal instance create {type} {name}',
|
||||
|
||||
// Docker Compose Management (✅ IMPLEMENTED)
|
||||
up: 'libreportal app up {appName}',
|
||||
down: 'libreportal app down {appName}',
|
||||
@ -48,6 +53,7 @@ class TaskCommands {
|
||||
system_status: 'implemented',
|
||||
system_update: 'implemented',
|
||||
system_reset: 'implemented',
|
||||
instance_create: 'implemented',
|
||||
|
||||
// ❌ Not yet implemented in CLI
|
||||
restore: 'not_implemented',
|
||||
@ -136,7 +142,8 @@ class TaskCommands {
|
||||
status: ['appName'],
|
||||
up: ['appName'],
|
||||
down: ['appName'],
|
||||
reload: ['appName']
|
||||
reload: ['appName'],
|
||||
instance_create: ['type', 'name']
|
||||
};
|
||||
|
||||
const required = requiredParams[type] || [];
|
||||
|
||||
@ -21,6 +21,9 @@ class TaskRouter {
|
||||
case 'install':
|
||||
return await this.actions.installApp(params.appName, params.config, params.resetNetwork);
|
||||
|
||||
case 'instance_create':
|
||||
return await this.actions.instanceCreate(params.type, params.name, params.domainIndex, params.subdomain);
|
||||
|
||||
case 'uninstall':
|
||||
return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks);
|
||||
|
||||
|
||||
56
scripts/cli/commands/instance/cli_instance_commands.sh
Normal file
56
scripts/cli/commands/instance/cli_instance_commands.sh
Normal file
@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Instance Commands Handler
|
||||
# Multi-instance lifecycle for multi-instance-capable apps. Mutating verbs route
|
||||
# through the task system (mirroring `app install`): the WebUI queues a task, the
|
||||
# processor re-invokes the CLI with LIBREPORTAL_TASK_EXEC=1, and only then does
|
||||
# the real work run. No new mutating backend API endpoint is introduced.
|
||||
|
||||
cliHandleInstanceCommands()
|
||||
{
|
||||
local action="$initial_command2"
|
||||
local type="$initial_command3"
|
||||
local name="$initial_command4"
|
||||
local domain_idx="$initial_command5"
|
||||
local subdomain="$initial_command6"
|
||||
|
||||
case "$action" in
|
||||
"create")
|
||||
if [[ -z "$type" || -z "$name" ]]; then
|
||||
isNotice "Usage: libreportal instance create <type> <name> [domain_index] [subdomain]"
|
||||
cliShowInstanceHelp
|
||||
return 1
|
||||
fi
|
||||
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
||||
instanceCreate "$type" "$name" "$domain_idx" "$subdomain"
|
||||
else
|
||||
local _cmd="libreportal instance create $type $name"
|
||||
[[ -n "$domain_idx" ]] && _cmd+=" $domain_idx"
|
||||
[[ -n "$subdomain" ]] && _cmd+=" $subdomain"
|
||||
cliTaskRun "$_cmd" "install" "${type}_$(instanceIdPart "$name")"
|
||||
fi
|
||||
;;
|
||||
|
||||
"remove"|"delete")
|
||||
# Here $type holds the instance slug (positional reuse).
|
||||
local slug="$type"
|
||||
if [[ -z "$slug" ]]; then
|
||||
isNotice "Usage: libreportal instance remove <slug>"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
||||
instanceRemove "$slug"
|
||||
else
|
||||
cliTaskRun "libreportal instance remove $slug" "uninstall" "$slug"
|
||||
fi
|
||||
;;
|
||||
|
||||
"list")
|
||||
instanceList "$type" # $type optional = filter by app type
|
||||
;;
|
||||
|
||||
*)
|
||||
cliShowInstanceHelp
|
||||
;;
|
||||
esac
|
||||
}
|
||||
25
scripts/cli/commands/instance/cli_instance_header.sh
Normal file
25
scripts/cli/commands/instance/cli_instance_header.sh
Normal file
@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Instance Commands Header
|
||||
# Shows available instance commands and help information
|
||||
|
||||
cliShowInstanceHelp()
|
||||
{
|
||||
echo ""
|
||||
echo "Available Instance Commands (* is required):"
|
||||
echo ""
|
||||
echo " Run more than one copy of a multi-instance-capable app (e.g. two"
|
||||
echo " Bookstack/WordPress sites). Each instance is a full, isolated app:"
|
||||
echo " its own data, DB, subdomain, backups and update cadence. Only apps"
|
||||
echo " with CFG_<TYPE>_MULTI_INSTANCE=true can be instanced."
|
||||
echo ""
|
||||
echo " libreportal instance create [type*] [name*] [domain#] [subdomain]"
|
||||
echo " - Provision + install a new instance."
|
||||
echo " type = base app slug (e.g. bookstack)"
|
||||
echo " name - instance name (e.g. blog)"
|
||||
echo " domain# - which CFG_DOMAIN_n to route on (default 1)"
|
||||
echo " subdomain- host label (default <type>-<name>)"
|
||||
echo " libreportal instance remove [slug*] - Uninstall + remove an instance (e.g. bookstack_blog)"
|
||||
echo " libreportal instance list [type] - List instances (all, or just for one app type)"
|
||||
echo ""
|
||||
}
|
||||
239
scripts/instance/instance_create.sh
Normal file
239
scripts/instance/instance_create.sh
Normal file
@ -0,0 +1,239 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Multi-instance support.
|
||||
#
|
||||
# Some apps are worth running more than once on a single box — e.g. two
|
||||
# WordPress/Bookstack sites, or a "family" and a "work" Nextcloud kept in
|
||||
# separate trust/blast-radius/backup domains. Internal multi-tenancy answers
|
||||
# logical separation; this answers instance-level isolation (independent data,
|
||||
# version cadence, admin, restore granularity).
|
||||
#
|
||||
# The model: an instance is just another app. It gets its own slug
|
||||
# (<type>_<id>), its own CFG_<SLUG>_* namespace, its own deployed dir, DB row,
|
||||
# IP/port allocation, subdomain and backups — so the entire downstream pipeline
|
||||
# (scan, install, services, routing, updater, backups) treats it like any other
|
||||
# app with ZERO changes. Everything instance-specific happens here, on a cloned
|
||||
# copy of the type's template, leaving the shipped template and the core engine
|
||||
# untouched.
|
||||
#
|
||||
# Only apps that opt in via CFG_<TYPE>_MULTI_INSTANCE=true can be instanced;
|
||||
# structurally-singleton apps (Traefik, DNS, VPN, the *arr stack, LibrePortal
|
||||
# itself) never get the flag.
|
||||
|
||||
# Read a CFG_<TYPE>_<KEY> value straight from a type's template config, without
|
||||
# relying on it already being sourced.
|
||||
instanceTypeCfg() {
|
||||
local type="$1" key="$2"
|
||||
local cfg="${install_containers_dir%/}/$type/$type.config"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
local line
|
||||
line=$(grep -E "^CFG_${type^^}_${key}=" "$cfg" | head -n1)
|
||||
[[ -z "$line" ]] && return 1
|
||||
line="${line#*=}"
|
||||
line="${line//$'\r'/}"
|
||||
line="${line#\"}"
|
||||
line="${line%\"}"
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
# Turn a user-supplied instance name into the <id> half of the slug. App configs
|
||||
# are SOURCED, so the slug (uppercased) must be a valid shell identifier — that
|
||||
# means [a-z0-9] only (underscores are fine, hyphens are not). Hostname-safety is
|
||||
# handled separately by the subdomain, which may contain hyphens.
|
||||
instanceIdPart() {
|
||||
local raw="${1,,}"
|
||||
raw="${raw//[^a-z0-9]/}"
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# Upsert a single CFG line in a config file (append if absent, else update).
|
||||
_instanceSetCfg() {
|
||||
local key="$1" val="$2" file="$3"
|
||||
if grep -qE "^${key}=" "$file"; then
|
||||
updateConfigOption "$key" "$val" "$file" >/dev/null
|
||||
else
|
||||
echo "${key}=\"${val}\"" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Rewrite field 10 (the subdomain column) of the instance's primary webui port so
|
||||
# the instance routes to its own host instead of inheriting the type's. Empty
|
||||
# subdomain would otherwise resolve to <slug>.<domain> — and the slug carries an
|
||||
# underscore, which isn't valid in a hostname.
|
||||
_instanceSetSubdomain() {
|
||||
local slug_u="$1" subdomain="$2" file="$3"
|
||||
local key="CFG_${slug_u}_PORT_1"
|
||||
local line
|
||||
line=$(grep -E "^${key}=" "$file" | head -n1)
|
||||
[[ -z "$line" ]] && return 0
|
||||
local val="${line#*=}"
|
||||
val="${val//$'\r'/}"
|
||||
val="${val#\"}"
|
||||
val="${val%\"}"
|
||||
local IFS='|'
|
||||
local -a f=($val)
|
||||
while [[ ${#f[@]} -lt 11 ]]; do f+=(""); done
|
||||
f[10]="$subdomain"
|
||||
local newval="${f[*]}"
|
||||
updateConfigOption "$key" "$newval" "$file" >/dev/null
|
||||
}
|
||||
|
||||
# Rewrite identity-bearing tokens in the cloned compose so the instance's
|
||||
# containers, Traefik routers and backup labels are unique. image: lines are
|
||||
# deliberately left untouched (rule 2/3 anchor on their line prefix; the
|
||||
# *-service / *_db tokens never appear in an image path).
|
||||
_instanceRewriteCompose() {
|
||||
local type="$1" slug="$2" dir="$3"
|
||||
local f="$dir/docker-compose.yml"
|
||||
[[ -f "$f" ]] || return 0
|
||||
# 1. Traefik router/service names (<type>-service) and the db container/host
|
||||
# (<type>_db) — these tokens are unambiguous, rewrite everywhere.
|
||||
sed -i -E "s/\b${type}-service\b/${slug}-service/g; s/\b${type}_db\b/${slug}_db/g" "$f"
|
||||
# 2. The standalone app container (container_name: <type>) — anchored so the
|
||||
# image: line ending in <type> is never touched.
|
||||
sed -i -E "s/(container_name:[[:space:]]*)${type}\b/\1${slug}/g" "$f"
|
||||
# 3. The files-backup label's container ref (libreportal.backup.files: "<type>:/...").
|
||||
sed -i -E "s/(libreportal\.backup\.files:[[:space:]]*\")${type}\b/\1${slug}/g" "$f"
|
||||
}
|
||||
|
||||
# Clone + prefix-rename the per-app tools/scripts so an instance's helpers target
|
||||
# its own container and don't collide (by function name) with the type's. This is
|
||||
# best-effort: it keeps the tool tree internally consistent, but apps with unusual
|
||||
# tool wiring may need review before their flag is flipped.
|
||||
_instanceRewriteTools() {
|
||||
local type="$1" slug="$2" dir="$3"
|
||||
local d f base
|
||||
for d in "$dir/tools" "$dir/scripts"; do
|
||||
[[ -d "$d" ]] || continue
|
||||
for f in "$d/${type}_"*.sh "$d/${type}.tools.json"; do
|
||||
[[ -e "$f" ]] || continue
|
||||
base="$(basename "$f")"
|
||||
mv "$f" "$d/${base/#${type}/${slug}}"
|
||||
done
|
||||
for f in "$d"/*.sh "$d"/*.json; do
|
||||
[[ -e "$f" ]] || continue
|
||||
# Uniform lowercase-prefix rename keeps file names, function defs and
|
||||
# tools.json ids consistent; then fix container-exec + config refs.
|
||||
sed -i -E "s/\b${type}_/${slug}_/g" "$f"
|
||||
sed -i -E "s/(docker[[:space:]]+(exec|logs|restart|stop|start|inspect)[[:space:]]+)${type}\b/\1${slug}/g" "$f"
|
||||
sed -i -E "s/\b${type}\.config\b/${slug}.config/g" "$f"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Provision and install a new instance of a multi-instance-capable app.
|
||||
# instanceCreate <type> <name> [domain_index] [subdomain]
|
||||
instanceCreate() {
|
||||
local type="$1" rawname="$2" domain_idx="$3" subdomain="$4"
|
||||
|
||||
local type_dir="${install_containers_dir%/}/$type"
|
||||
if [[ -z "$type" || ! -d "$type_dir" || ! -f "$type_dir/$type.config" ]]; then
|
||||
isError "Instance create: unknown app type '$type'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local capable
|
||||
capable=$(instanceTypeCfg "$type" "MULTI_INSTANCE")
|
||||
if [[ "$capable" != "true" ]]; then
|
||||
isError "App type '$type' is not multi-instance-capable. Set CFG_${type^^}_MULTI_INSTANCE=true on a reviewed app to allow it."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local id
|
||||
id=$(instanceIdPart "$rawname")
|
||||
if [[ -z "$id" ]]; then
|
||||
isError "Instance create: '$rawname' has no usable letters/digits for an instance name."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local slug="${type}_${id}"
|
||||
local slug_u="${slug^^}"
|
||||
if [[ -d "${install_containers_dir%/}/$slug" || -d "${containers_dir%/}/$slug" ]]; then
|
||||
isError "An app or instance named '$slug' already exists. Pick a different name."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Default the host to a hyphen-safe form of the slug; let the caller override.
|
||||
[[ -z "$subdomain" ]] && subdomain="${slug//_/-}"
|
||||
|
||||
isNotice "Creating new '$type' instance '$id' (slug: $slug, host: ${subdomain}.<domain>)"
|
||||
|
||||
# 1. Clone the type's template tree into a new instance template.
|
||||
local inst_dir="${install_containers_dir%/}/$slug"
|
||||
cp -r "$type_dir" "$inst_dir"
|
||||
if [[ ! -d "$inst_dir" ]]; then
|
||||
isError "Instance create: failed to clone template for '$slug'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 2. Rename the files that are keyed by the type slug.
|
||||
[[ -f "$inst_dir/$type.config" ]] && mv "$inst_dir/$type.config" "$inst_dir/$slug.config"
|
||||
[[ -f "$inst_dir/$type.svg" ]] && cp "$inst_dir/$type.svg" "$inst_dir/$slug.svg"
|
||||
[[ -f "$inst_dir/$type.png" ]] && cp "$inst_dir/$type.png" "$inst_dir/$slug.png"
|
||||
|
||||
local cfg="$inst_dir/$slug.config"
|
||||
|
||||
# 3. Re-namespace the config (CFG_<TYPE>_* -> CFG_<SLUG>_*) then stamp the
|
||||
# instance metadata. Secrets keep their RANDOMIZED* placeholders so the
|
||||
# install-time scanner mints fresh ones — instances never share secrets.
|
||||
sed -i -E "s/CFG_${type^^}_/CFG_${slug_u}_/g" "$cfg"
|
||||
|
||||
local type_title
|
||||
type_title=$(instanceTypeCfg "$type" "TITLE")
|
||||
[[ -z "$type_title" ]] && type_title="$type"
|
||||
|
||||
_instanceSetCfg "CFG_${slug_u}_INSTANCE_OF" "$type" "$cfg"
|
||||
_instanceSetCfg "CFG_${slug_u}_MULTI_INSTANCE" "false" "$cfg"
|
||||
_instanceSetCfg "CFG_${slug_u}_TITLE" "${type_title} · ${id}" "$cfg"
|
||||
[[ -n "$domain_idx" ]] && _instanceSetCfg "CFG_${slug_u}_DOMAIN" "$domain_idx" "$cfg"
|
||||
_instanceSetSubdomain "$slug_u" "$subdomain" "$cfg"
|
||||
|
||||
# 4. Make the cloned compose + tools target the instance's own identity.
|
||||
_instanceRewriteCompose "$type" "$slug" "$inst_dir"
|
||||
_instanceRewriteTools "$type" "$slug" "$inst_dir"
|
||||
|
||||
isSuccessful "Instance template ready: $slug (instance of $type)"
|
||||
|
||||
# 5. Hand off to the standard installer — from here it's just another app.
|
||||
if ! declare -F dockerInstallApp >/dev/null 2>&1; then
|
||||
isError "dockerInstallApp unavailable; instance template created but not installed."
|
||||
return 1
|
||||
fi
|
||||
dockerInstallApp "$slug" "" "false"
|
||||
}
|
||||
|
||||
# List instances, optionally filtered to one type. An instance is any app whose
|
||||
# config declares CFG_<SLUG>_INSTANCE_OF.
|
||||
instanceList() {
|
||||
local want_type="$1"
|
||||
local dir folder slug instance_of
|
||||
for dir in "${install_containers_dir%/}"/*/; do
|
||||
folder="$(basename "$dir")"
|
||||
[[ -f "$dir/$folder.config" ]] || continue
|
||||
instance_of=$(grep -E "^CFG_${folder^^}_INSTANCE_OF=" "$dir/$folder.config" 2>/dev/null | head -n1)
|
||||
[[ -z "$instance_of" ]] && continue
|
||||
instance_of="${instance_of#*=}"; instance_of="${instance_of//\"/}"; instance_of="${instance_of//$'\r'/}"
|
||||
[[ -n "$want_type" && "$instance_of" != "$want_type" ]] && continue
|
||||
echo "$folder (instance of $instance_of)"
|
||||
done
|
||||
}
|
||||
|
||||
# Remove an instance: standard uninstall (deployed dir + DB + compose down) then
|
||||
# drop the instance's template clone. Refuses to touch a non-instance app.
|
||||
instanceRemove() {
|
||||
local slug="$1"
|
||||
local cfg="${install_containers_dir%/}/$slug/$slug.config"
|
||||
if [[ ! -f "$cfg" ]]; then
|
||||
isError "Instance remove: no such instance '$slug'."
|
||||
return 1
|
||||
fi
|
||||
if ! grep -qE "^CFG_${slug^^}_INSTANCE_OF=" "$cfg"; then
|
||||
isError "'$slug' is a base app, not an instance — uninstall it via 'libreportal app uninstall $slug'."
|
||||
return 1
|
||||
fi
|
||||
if declare -F dockerUninstallApp >/dev/null 2>&1; then
|
||||
dockerUninstallApp "$slug" "false" "false"
|
||||
fi
|
||||
rm -rf "${install_containers_dir%/}/$slug"
|
||||
isSuccessful "Removed instance '$slug'."
|
||||
}
|
||||
@ -27,6 +27,8 @@ cli_scripts=(
|
||||
"cli/commands/help/cli_help_header.sh"
|
||||
"cli/commands/install/cli_install_commands.sh"
|
||||
"cli/commands/install/cli_install_header.sh"
|
||||
"cli/commands/instance/cli_instance_commands.sh"
|
||||
"cli/commands/instance/cli_instance_header.sh"
|
||||
"cli/commands/ip/cli_ip_commands.sh"
|
||||
"cli/commands/ip/cli_ip_header.sh"
|
||||
"cli/commands/peer/cli_peer_commands.sh"
|
||||
|
||||
9
scripts/source/files/arrays/files_instance.sh
Normal file
9
scripts/source/files/arrays/files_instance.sh
Normal file
@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This file is auto-generated by generate_arrays.sh
|
||||
# Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate
|
||||
|
||||
instance_scripts=(
|
||||
"instance/instance_create.sh"
|
||||
|
||||
)
|
||||
@ -16,6 +16,7 @@ source_scripts=(
|
||||
"source/files/arrays/files_docker.sh"
|
||||
"source/files/arrays/files_function.sh"
|
||||
"source/files/arrays/files_install.sh"
|
||||
"source/files/arrays/files_instance.sh"
|
||||
"source/files/arrays/files_logs.sh"
|
||||
"source/files/arrays/files_menu.sh"
|
||||
"source/files/arrays/files_migrate.sh"
|
||||
|
||||
@ -279,6 +279,7 @@ declare -gA LP_FN_MAP=(
|
||||
[cliHandleFirewallCommands]="cli/commands/firewall/cli_firewall_commands.sh"
|
||||
[cliHandleHelpCommands]="cli/commands/help/cli_help_commands.sh"
|
||||
[cliHandleInstallCommands]="cli/commands/install/cli_install_commands.sh"
|
||||
[cliHandleInstanceCommands]="cli/commands/instance/cli_instance_commands.sh"
|
||||
[cliHandleIPCommands]="cli/commands/ip/cli_ip_commands.sh"
|
||||
[cliHandlePeerCommands]="cli/commands/peer/cli_peer_commands.sh"
|
||||
[cliHandleRegenCommands]="cli/commands/regen/cli_regen_commands.sh"
|
||||
@ -302,6 +303,7 @@ declare -gA LP_FN_MAP=(
|
||||
[cliShowDockertypeHelp]="cli/commands/dockertype/cli_dockertype_header.sh"
|
||||
[cliShowHelpHelp]="cli/commands/help/cli_help_header.sh"
|
||||
[cliShowInstallHelp]="cli/commands/install/cli_install_header.sh"
|
||||
[cliShowInstanceHelp]="cli/commands/instance/cli_instance_header.sh"
|
||||
[cliShowIPHelp]="cli/commands/ip/cli_ip_header.sh"
|
||||
[cliShowPeerHelp]="cli/commands/peer/cli_peer_header.sh"
|
||||
[cliShowRegenHelp]="cli/commands/regen/cli_regen_header.sh"
|
||||
@ -519,6 +521,15 @@ declare -gA LP_FN_MAP=(
|
||||
[installSwapfile]="install/install_swapfile.sh"
|
||||
[installUFW]="install/install_ufw.sh"
|
||||
[installUFWDocker]="install/install_ufwd.sh"
|
||||
[instanceCreate]="instance/instance_create.sh"
|
||||
[instanceIdPart]="instance/instance_create.sh"
|
||||
[instanceList]="instance/instance_create.sh"
|
||||
[instanceRemove]="instance/instance_create.sh"
|
||||
[_instanceRewriteCompose]="instance/instance_create.sh"
|
||||
[_instanceRewriteTools]="instance/instance_create.sh"
|
||||
[_instanceSetCfg]="instance/instance_create.sh"
|
||||
[_instanceSetSubdomain]="instance/instance_create.sh"
|
||||
[instanceTypeCfg]="instance/instance_create.sh"
|
||||
[_invidiousBcrypt]="invidious/scripts/invidious_auth.sh"
|
||||
[_invidiousPsql]="invidious/scripts/invidious_auth.sh"
|
||||
[invidiousToolsMenu]="menu/tools/manage_invidious.sh"
|
||||
@ -1226,6 +1237,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[cliHandleFirewallCommands]="scripts"
|
||||
[cliHandleHelpCommands]="scripts"
|
||||
[cliHandleInstallCommands]="scripts"
|
||||
[cliHandleInstanceCommands]="scripts"
|
||||
[cliHandleIPCommands]="scripts"
|
||||
[cliHandlePeerCommands]="scripts"
|
||||
[cliHandleRegenCommands]="scripts"
|
||||
@ -1249,6 +1261,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[cliShowDockertypeHelp]="scripts"
|
||||
[cliShowHelpHelp]="scripts"
|
||||
[cliShowInstallHelp]="scripts"
|
||||
[cliShowInstanceHelp]="scripts"
|
||||
[cliShowIPHelp]="scripts"
|
||||
[cliShowPeerHelp]="scripts"
|
||||
[cliShowRegenHelp]="scripts"
|
||||
@ -1466,6 +1479,15 @@ declare -gA LP_FN_ROOT=(
|
||||
[installSwapfile]="scripts"
|
||||
[installUFW]="scripts"
|
||||
[installUFWDocker]="scripts"
|
||||
[instanceCreate]="scripts"
|
||||
[instanceIdPart]="scripts"
|
||||
[instanceList]="scripts"
|
||||
[instanceRemove]="scripts"
|
||||
[_instanceRewriteCompose]="scripts"
|
||||
[_instanceRewriteTools]="scripts"
|
||||
[_instanceSetCfg]="scripts"
|
||||
[_instanceSetSubdomain]="scripts"
|
||||
[instanceTypeCfg]="scripts"
|
||||
[_invidiousBcrypt]="containers"
|
||||
[_invidiousPsql]="containers"
|
||||
[invidiousToolsMenu]="scripts"
|
||||
@ -2194,6 +2216,7 @@ cliHandleDockertypeCommands() { source "${install_scripts_dir}cli/commands/docke
|
||||
cliHandleFirewallCommands() { source "${install_scripts_dir}cli/commands/firewall/cli_firewall_commands.sh"; cliHandleFirewallCommands "$@"; }
|
||||
cliHandleHelpCommands() { source "${install_scripts_dir}cli/commands/help/cli_help_commands.sh"; cliHandleHelpCommands "$@"; }
|
||||
cliHandleInstallCommands() { source "${install_scripts_dir}cli/commands/install/cli_install_commands.sh"; cliHandleInstallCommands "$@"; }
|
||||
cliHandleInstanceCommands() { source "${install_scripts_dir}cli/commands/instance/cli_instance_commands.sh"; cliHandleInstanceCommands "$@"; }
|
||||
cliHandleIPCommands() { source "${install_scripts_dir}cli/commands/ip/cli_ip_commands.sh"; cliHandleIPCommands "$@"; }
|
||||
cliHandlePeerCommands() { source "${install_scripts_dir}cli/commands/peer/cli_peer_commands.sh"; cliHandlePeerCommands "$@"; }
|
||||
cliHandleRegenCommands() { source "${install_scripts_dir}cli/commands/regen/cli_regen_commands.sh"; cliHandleRegenCommands "$@"; }
|
||||
@ -2217,6 +2240,7 @@ cliShowDebugHelp() { source "${install_scripts_dir}cli/commands/debug/cli_debug_
|
||||
cliShowDockertypeHelp() { source "${install_scripts_dir}cli/commands/dockertype/cli_dockertype_header.sh"; cliShowDockertypeHelp "$@"; }
|
||||
cliShowHelpHelp() { source "${install_scripts_dir}cli/commands/help/cli_help_header.sh"; cliShowHelpHelp "$@"; }
|
||||
cliShowInstallHelp() { source "${install_scripts_dir}cli/commands/install/cli_install_header.sh"; cliShowInstallHelp "$@"; }
|
||||
cliShowInstanceHelp() { source "${install_scripts_dir}cli/commands/instance/cli_instance_header.sh"; cliShowInstanceHelp "$@"; }
|
||||
cliShowIPHelp() { source "${install_scripts_dir}cli/commands/ip/cli_ip_header.sh"; cliShowIPHelp "$@"; }
|
||||
cliShowPeerHelp() { source "${install_scripts_dir}cli/commands/peer/cli_peer_header.sh"; cliShowPeerHelp "$@"; }
|
||||
cliShowRegenHelp() { source "${install_scripts_dir}cli/commands/regen/cli_regen_header.sh"; cliShowRegenHelp "$@"; }
|
||||
@ -2434,6 +2458,15 @@ installSSLCertificate() { source "${install_scripts_dir}install/install_certific
|
||||
installSwapfile() { source "${install_scripts_dir}install/install_swapfile.sh"; installSwapfile "$@"; }
|
||||
installUFW() { source "${install_scripts_dir}install/install_ufw.sh"; installUFW "$@"; }
|
||||
installUFWDocker() { source "${install_scripts_dir}install/install_ufwd.sh"; installUFWDocker "$@"; }
|
||||
instanceCreate() { source "${install_scripts_dir}instance/instance_create.sh"; instanceCreate "$@"; }
|
||||
instanceIdPart() { source "${install_scripts_dir}instance/instance_create.sh"; instanceIdPart "$@"; }
|
||||
instanceList() { source "${install_scripts_dir}instance/instance_create.sh"; instanceList "$@"; }
|
||||
instanceRemove() { source "${install_scripts_dir}instance/instance_create.sh"; instanceRemove "$@"; }
|
||||
_instanceRewriteCompose() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteCompose "$@"; }
|
||||
_instanceRewriteTools() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteTools "$@"; }
|
||||
_instanceSetCfg() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceSetCfg "$@"; }
|
||||
_instanceSetSubdomain() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceSetSubdomain "$@"; }
|
||||
instanceTypeCfg() { source "${install_scripts_dir}instance/instance_create.sh"; instanceTypeCfg "$@"; }
|
||||
_invidiousBcrypt() { source "${install_containers_dir}invidious/scripts/invidious_auth.sh"; _invidiousBcrypt "$@"; }
|
||||
_invidiousPsql() { source "${install_containers_dir}invidious/scripts/invidious_auth.sh"; _invidiousPsql "$@"; }
|
||||
invidiousToolsMenu() { source "${install_scripts_dir}menu/tools/manage_invidious.sh"; invidiousToolsMenu "$@"; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user