Snippets: add built-in templates (collapsed "Templates" section in the snippets view and the terminal snippet panel): authorize an SSH key from the keychain for a user, install the QEMU guest agent, set/remove an APT cache server (apt-cacher-ng), system update; parameters are asked for before running

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:37:31 +02:00
co-authored by Claude Opus 5.5
parent 7d4991c99c
commit 408b4bd9d0
5 changed files with 108 additions and 5 deletions
+16
View File
@@ -351,6 +351,22 @@
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
'Templates': 'Vorlagen',
'Run': 'Ausführen',
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
'Key': 'Schlüssel',
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
'User is missing.': 'Benutzer fehlt.',
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
'Use APT cache server': 'APT-Cache-Server verwenden',
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
'Proxy URL': 'Proxy-URL',
'Invalid URL.': 'Ungültige URL.',
'Remove APT cache server': 'APT-Cache-Server entfernen',
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
'Wrong PIN.': 'Falsche PIN.',
+83 -2
View File
@@ -658,7 +658,7 @@ function viewSnippets(page) {
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet()));
if (!list.length) { c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); c.append(templateList()); return; }
const l = h('<div class="list"></div>');
for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
@@ -671,7 +671,7 @@ function viewSnippets(page) {
};
l.append(card);
}
c.append(l);
c.append(l, templateList());
}
function editSnippet(s = {}) {
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
@@ -685,6 +685,86 @@ function editSnippet(s = {}) {
reload();
});
}
// Eingebaute Vorlagen (nicht im Vault). Parameter werden vor dem Ausführen abgefragt; build() liefert eine Zeile.
// $S = sudo, außer man ist bereits root.
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
const SUDO = 'S=$([ "$(id -u)" = 0 ] || echo sudo);';
const SNIPPET_TEMPLATES = [
{
label: () => T('Authorize SSH key for a user'),
desc: () => T('Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.'),
params: () => [
S.vault.keys.some((k) => k.publicKey)
? field(T('Key'), 'key', '', { type: 'select', options: S.vault.keys.filter((k) => k.publicKey).map((k) => [k.id, k.label]) })
: field('Public Key', 'pub', '', { type: 'textarea', placeholder: 'ssh-ed25519 AAAA…' }),
field(T('User'), 'user', 'root'),
],
build: (v) => {
const pub = (v.key ? S.vault.keys.find((k) => k.id === v.key)?.publicKey : v.pub || '').trim();
if (!pub) throw new Error(T('Public key is missing.'));
if (!v.user.trim()) throw new Error(T('User is missing.'));
return `${SUDO} u=${shq(v.user.trim())}; k=${shq(pub)}; d="$(getent passwd "$u" | cut -d: -f6)/.ssh"; `
+ `$S install -d -m 700 -o "$u" -g "$(id -gn "$u")" "$d" && { $S grep -qxF "$k" "$d/authorized_keys" 2>/dev/null || echo "$k" | $S tee -a "$d/authorized_keys" >/dev/null; } `
+ `&& $S chown "$u": "$d/authorized_keys" && $S chmod 600 "$d/authorized_keys" && echo "OK: $d/authorized_keys"`;
},
},
{
label: () => T('Install QEMU guest agent'),
desc: () => T('Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).'),
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get install -y qemu-guest-agent; `
+ 'elif command -v dnf >/dev/null; then $S dnf install -y qemu-guest-agent; elif command -v yum >/dev/null; then $S yum install -y qemu-guest-agent; '
+ 'elif command -v pacman >/dev/null; then $S pacman -S --needed --noconfirm qemu-guest-agent; elif command -v zypper >/dev/null; then $S zypper -n install qemu-guest-agent; '
+ 'elif command -v apk >/dev/null; then $S apk add qemu-guest-agent && $S rc-update add qemu-guest-agent && $S rc-service qemu-guest-agent start; fi; '
+ 'command -v systemctl >/dev/null && { $S systemctl enable --now qemu-guest-agent 2>/dev/null || $S systemctl start qemu-guest-agent; }; '
+ 'command -v systemctl >/dev/null && systemctl is-active qemu-guest-agent',
},
{
label: () => T('Use APT cache server'),
desc: () => T('Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).'),
params: () => [field(T('Proxy URL'), 'url', 'http://', { placeholder: 'http://192.168.1.10:3142' })],
build: (v) => {
const url = v.url.trim();
if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(T('Invalid URL.'));
return `${SUDO} echo ${shq(`Acquire::http::Proxy "${url}";`)} | $S tee /etc/apt/apt.conf.d/00proxy && $S apt-get update`;
},
},
{
label: () => T('Remove APT cache server'),
desc: () => T('Removes the APT proxy setting again.'),
build: () => `${SUDO} $S rm -f /etc/apt/apt.conf.d/00proxy && $S apt-get update`,
},
{
label: () => T('System update'),
desc: () => T('Updates all packages with the system package manager.'),
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get -y full-upgrade; elif command -v dnf >/dev/null; then $S dnf -y upgrade; `
+ 'elif command -v pacman >/dev/null; then $S pacman -Syu --noconfirm; elif command -v zypper >/dev/null; then $S zypper -n update; elif command -v apk >/dev/null; then $S apk upgrade --update; fi',
},
];
// Fragt die Parameter einer Vorlage ab und schickt den Befehl ins Terminal
async function runTemplate(t, session) {
const params = t.params ? t.params() : [];
let v = {};
if (params.length) {
const body = `<p style="margin-top:0;color:var(--muted)">${esc(t.desc())}</p>${params.map((el) => el.outerHTML).join('')}`;
v = await modal({ title: t.label(), body, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Run'), value: 'form', cls: 'primary' }] });
if (!v) return;
}
let command;
try { command = t.build(v); } catch (e) { return toast(e.message, 'error'); }
runSnippet({ command, autoRun: true }, session);
}
function templateList(session) {
const d = h(`<details class="snip-tpl"><summary>${T('Templates')}</summary><div class="tpl-items"></div></details>`);
for (const t of SNIPPET_TEMPLATES) {
const it = h(`<div class="it"><div>${esc(t.label())}</div><div class="hint">${esc(t.desc())}</div></div>`);
it.onclick = () => runTemplate(t, session);
$('.tpl-items', d).append(it);
}
return d;
}
function runSnippet(s, session) {
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
if (!t) return toast(T('No open terminal'), 'error');
@@ -1504,6 +1584,7 @@ class TerminalSession {
it.onclick = () => runSnippet(s, this);
box.append(it);
});
box.append(templateList(this));
}
toggleFind(open) {
+6
View File
@@ -191,6 +191,12 @@ body.in-session #sidebar { display: none; }
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.snip-panel .it:hover { background: var(--card-hover); }
.snip-tpl { margin-top: 10px; }
.snip-tpl summary { cursor: pointer; color: var(--muted); font-weight: 600; padding: 6px 2px; user-select: none; }
.snip-tpl .tpl-items { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.snip-tpl .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.snip-tpl .it:hover { background: var(--card-hover); }
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
.findbar.open { display: flex; }