feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash). - Users: row menu (Edit + Open home in Files for an admin, Copy username/email). - Artifacts: card menu repeating its buttons, plus Copy link for a page. - Git identity: named SSH keys beside the default ones (id_<type>_<name>, max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` → `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host), each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of that URL offers only that key and persists it. Terminal sshCommand follows the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and other-host forms. Docs/API.md, OpenAPI updated.
This commit is contained in:
+212
-43
@@ -23,8 +23,14 @@
|
||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit
|
||||
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
|
||||
* overwritten), plus SSH logins (`setSshUser`: AWS CodeCommit wants the IAM
|
||||
* SSH key ID as the user) as `url.<…>.insteadOf` rules there — one per host,
|
||||
* or one per ALIAS (`ssh://aws-prod/…`), so several accounts on one host
|
||||
* (two AWS accounts, two GitHub users) each log in as themselves. Besides
|
||||
* the default key of each type, an account may hold named keys
|
||||
* (`ssh/id_<type>_<name>`), and a login may name the key it uses
|
||||
* (`agenthub.<from>.sshKey` in the same file): a clone of that login's URL
|
||||
* offers ONLY that key and keeps it in the clone's config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -74,6 +80,12 @@ function accountForRequest(req) {
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
/** A named key's name, and every key's id (= its file name in `sshDir`). */
|
||||
const KEY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const KEY_ID_RE = /^id_(ed25519|rsa)(?:_([a-z0-9][a-z0-9-]{0,31}))?$/;
|
||||
/** ssh offers every key in turn and servers stop after ~6 tries. */
|
||||
const MAX_KEYS = 10;
|
||||
const keyIdFor = (type, name) => (name ? `${KEY_FILES[type]}_${name}` : KEY_FILES[type]);
|
||||
|
||||
function paths(account) {
|
||||
if (account.kind === "member") {
|
||||
@@ -214,17 +226,32 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
|
||||
/**
|
||||
* `[{id, type, name, file, publicKey}]` of the account's keys: the default
|
||||
* ed25519 and rsa ones first (`name` null), then the named ones by name.
|
||||
*/
|
||||
function currentKeys(account) {
|
||||
const { sshDir } = paths(account);
|
||||
let ids;
|
||||
try {
|
||||
ids = fs.readdirSync(sshDir).filter((n) => KEY_ID_RE.test(n));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rank = (id) => {
|
||||
const i = Object.values(KEY_FILES).indexOf(id);
|
||||
return i < 0 ? Object.keys(KEY_FILES).length : i;
|
||||
};
|
||||
ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
const keys = [];
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
for (const id of ids) {
|
||||
const [, type, name = null] = KEY_ID_RE.exec(id);
|
||||
const file = path.join(sshDir, id);
|
||||
const publicKey = readText(account, `${file}.pub`);
|
||||
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, file)) continue;
|
||||
keys.push({ type, file, publicKey: publicKey.trim() });
|
||||
keys.push({ id, type, name, file, publicKey: publicKey.trim() });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -235,10 +262,12 @@ const keyFiles = (account) => currentKeys(account).map((k) => k.file);
|
||||
/** ssh options naming `files` — `-i` each, in order. */
|
||||
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
|
||||
|
||||
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
/** `[{id, type, name, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
function sshKeysInfo(account) {
|
||||
return currentKeys(account).map((k) => ({
|
||||
id: k.id,
|
||||
type: k.type,
|
||||
name: k.name,
|
||||
publicKey: k.publicKey,
|
||||
fingerprint: fingerprint(k.publicKey),
|
||||
}));
|
||||
@@ -248,14 +277,30 @@ function sshKeysInfo(account) {
|
||||
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
|
||||
|
||||
/**
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa); an
|
||||
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
|
||||
* other type is left alone — a host refusing one type keeps the other.
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa) — the
|
||||
* default one of that type, or with `name` one more beside it (another AWS
|
||||
* account, another GitHub user). An existing key of that id is replaced only
|
||||
* with `replace`; every other key is left alone.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
async function generateSshKey(
|
||||
account,
|
||||
{ replace = false, comment, type = "ed25519", name = null } = {}
|
||||
) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (currentKeys(account).some((k) => k.type === type) && !replace) {
|
||||
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
|
||||
const keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
|
||||
if (keyName !== null && !KEY_NAME_RE.test(keyName)) {
|
||||
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
|
||||
}
|
||||
const id = keyIdFor(type, keyName);
|
||||
const keys = currentKeys(account);
|
||||
if (keys.some((k) => k.id === id) && !replace) {
|
||||
throw err("EEXISTS", `the SSH key ${keyName ?? type} already exists — replace it explicitly`);
|
||||
}
|
||||
if (keyName !== null && keys.some((k) => k.name === keyName && k.id !== id)) {
|
||||
throw err("EEXISTS", `a key named ${keyName} already exists`);
|
||||
}
|
||||
if (!keys.some((k) => k.id === id) && keys.length >= MAX_KEYS) {
|
||||
throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
|
||||
}
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
@@ -264,29 +309,44 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
|
||||
const { privateKey, publicKey } =
|
||||
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
|
||||
const { sshDir } = paths(account);
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const file = path.join(sshDir, id);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
// A member's home is AgentHub's to set up: their terminal git uses the key
|
||||
// too, unless they wrote a core.sshCommand of their own. The host's is
|
||||
// its admin's choice (the Settings switch).
|
||||
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
|
||||
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
|
||||
// its admin's choice (the Settings switch) — once on, it follows the keys.
|
||||
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account's key of `type`, or every key when `type` is omitted;
|
||||
* false when there was none. The terminal sshCommand goes with the last key.
|
||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||
* neither is given; false when there was none. A login naming a deleted key
|
||||
* goes back to offering every key; the terminal sshCommand follows the keys
|
||||
* and goes with the last one.
|
||||
*/
|
||||
async function removeSshKey(account, type) {
|
||||
async function removeSshKey(account, which) {
|
||||
const { type, id } = typeof which === "string" ? { type: which } : which || {};
|
||||
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
|
||||
if (id != null && !KEY_ID_RE.test(id)) throw err("EBADKEYNAME", "no such key id");
|
||||
const gone = currentKeys(account).filter((k) =>
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
}
|
||||
const ids = new Set(gone.map((k) => k.id));
|
||||
for (const [from, key] of await keyLinks(account)) {
|
||||
if (ids.has(key)) await unlinkKey(account, from);
|
||||
}
|
||||
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
|
||||
else if ((await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -427,11 +487,28 @@ async function setAuthor(account, { name, email }) {
|
||||
*/
|
||||
function terminalSshCommand(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
|
||||
const files = new Set([
|
||||
...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)),
|
||||
...currentKeys(account).map((k) => k.file),
|
||||
]);
|
||||
const ours = [...files].map((f) => `-o IdentityFile=${shq(f)}`);
|
||||
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
|
||||
return ["ssh", ...ours, ...defaults].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `cmd` is a terminal sshCommand WE wrote — of our shape, naming
|
||||
* key files in this account's AgentHub folder (any set of them: it follows
|
||||
* the keys as they are made and deleted) and nothing else but the defaults.
|
||||
*/
|
||||
function isOurTerminal(account, cmd) {
|
||||
const m = /^ssh((?: -o IdentityFile=(?:'[^']*'|~\/\.ssh\/id_[a-z0-9]+))+)$/.exec(cmd || "");
|
||||
if (!m) return false;
|
||||
const files = [...m[1].matchAll(/IdentityFile='([^']*)'/g)].map((x) => x[1]);
|
||||
const { sshDir } = paths(account);
|
||||
return files.length > 0 && files.every((f) => path.dirname(f) === sshDir);
|
||||
}
|
||||
|
||||
/** The account's global `core.sshCommand`, or "" when unset. */
|
||||
async function globalSshCommand(account) {
|
||||
const { git } = require("./worktree");
|
||||
@@ -449,7 +526,7 @@ async function globalSshCommand(account) {
|
||||
async function terminalSshState(account) {
|
||||
const cmd = await globalSshCommand(account);
|
||||
if (!cmd) return "off";
|
||||
return cmd === terminalSshCommand(account) ? "on" : "foreign";
|
||||
return isOurTerminal(account, cmd) ? "on" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,7 +541,8 @@ async function setTerminalSsh(account, on) {
|
||||
if (state === "foreign") {
|
||||
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
|
||||
}
|
||||
if (state === "off") {
|
||||
// Off, or ours naming another set of keys than the account holds now.
|
||||
if (state === "off" || (await globalSshCommand(account)) !== terminalSshCommand(account)) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
@@ -487,9 +565,47 @@ async function setTerminalSsh(account, on) {
|
||||
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
|
||||
// user name is no secret (an ID, not a key), so it may cross sudo's argv.
|
||||
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||
// An alias has no dot, so it can never shadow a real host name.
|
||||
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||
|
||||
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
|
||||
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
|
||||
async function keyLinks(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--get-regexp",
|
||||
"^agenthub\\..*\\.sshkey$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = new Map();
|
||||
for (const line of String(out).split("\n")) {
|
||||
const m = /^agenthub\.(.+)\.sshkey (\S+)$/.exec(line);
|
||||
if (m && KEY_ID_RE.test(m[2])) links.set(m[1], m[2]);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
async function unlinkKey(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
`agenthub.${from}.sshKey`,
|
||||
]).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* `[{host, user, alias, key}]` — only the rules of exactly our shape; any
|
||||
* other insteadOf is the user's. `alias` is null for the host's own rule
|
||||
* (`ssh://<host>/` logs in as `user`), else the name standing for that
|
||||
* user@host (`ssh://<alias>/`); `key` is the key id the login uses, or null
|
||||
* for every key.
|
||||
*/
|
||||
async function listSshUsers(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
@@ -501,21 +617,35 @@ async function listSshUsers(account) {
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = await keyLinks(account);
|
||||
const rows = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
|
||||
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
|
||||
rows.push({ host: m[2], user: m[1] });
|
||||
if (
|
||||
m &&
|
||||
(m[2] === m[3] || ALIAS_RE.test(m[3])) &&
|
||||
SSH_USER_RE.test(m[1]) &&
|
||||
HOST_RE.test(m[2])
|
||||
) {
|
||||
rows.push({
|
||||
host: m[2],
|
||||
user: m[1],
|
||||
alias: m[2] === m[3] ? null : m[3],
|
||||
key: links.get(m[3]) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Drop our rule for `host` (whatever its user); false when there was none. */
|
||||
async function removeSshUser(account, host) {
|
||||
/**
|
||||
* Drop our rule for `from` — a host's own rule, or an alias's — and the key
|
||||
* it names; false when there was none.
|
||||
*/
|
||||
async function removeSshUser(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
const h = String(host || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
|
||||
const f = String(from || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
|
||||
for (const r of rows) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
@@ -523,34 +653,68 @@ async function removeSshUser(account, host) {
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||
`ssh://${r.host}/`,
|
||||
`ssh://${f}/`,
|
||||
]);
|
||||
}
|
||||
if (rows.length) await unlinkKey(account, f);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
|
||||
async function setSshUser(account, { host, user } = {}) {
|
||||
/**
|
||||
* Log in to `host` over SSH as `user` — for `ssh://<host>/` itself, or, with
|
||||
* `alias`, for `ssh://<alias>/` (one more account on the same host) — with
|
||||
* the key `key` only, or every key when it is null. Replaces our earlier rule
|
||||
* for that host or alias.
|
||||
*/
|
||||
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
|
||||
const h = String(host || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const u = String(user || "").trim();
|
||||
const a = alias == null || alias === "" ? null : String(alias).trim().toLowerCase();
|
||||
if (!HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
|
||||
}
|
||||
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
|
||||
await removeSshUser(account, h);
|
||||
if (a !== null && !ALIAS_RE.test(a)) {
|
||||
throw err("EBADALIAS", "an account name of lowercase letters, digits and - (no dot)");
|
||||
}
|
||||
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
|
||||
throw err("ENOKEY", "no such SSH key");
|
||||
}
|
||||
const from = a ?? h;
|
||||
await removeSshUser(account, from);
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.${insteadOfBase(h, u)}.insteadOf`,
|
||||
`ssh://${h}/`,
|
||||
`ssh://${from}/`,
|
||||
]);
|
||||
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${from}.sshKey`, key]);
|
||||
return listSshUsers(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key file a clone of `url` must use: the one its login names, when the
|
||||
* URL is `ssh://<host or alias>/…` with no user of its own (the only shape
|
||||
* our insteadOf rules rewrite). Null: offer every key.
|
||||
*/
|
||||
async function linkedKeyFile(account, url) {
|
||||
if (!url.startsWith("ssh://")) return null;
|
||||
let from;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.username) return null;
|
||||
from = u.host.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = (await listSshUsers(account)).find((r) => (r.alias ?? r.host) === from && r.key);
|
||||
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
@@ -582,7 +746,7 @@ async function sshConfigFor(dir) {
|
||||
}
|
||||
// Our own terminal sshCommand (global) is not the repo's choice: it has no
|
||||
// BatchMode, so it would leave a dashboard fetch waiting on a prompt.
|
||||
if (own && own !== terminalSshCommand(account)) return [];
|
||||
if (own && !isOurTerminal(account, own)) return [];
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
@@ -692,18 +856,23 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const keys = keyFiles(account);
|
||||
// A login naming its key (another AWS account, a second GitHub user)
|
||||
// offers that key ONLY — another key could log in as the wrong account.
|
||||
const linked = await linkedKeyFile(account, url);
|
||||
const keys = linked ? [linked] : keyFiles(account);
|
||||
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
||||
if (keys.length) {
|
||||
// Persisted by FILE name, both types: a key made later is offered too
|
||||
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
|
||||
// Persisted by FILE name — both default types plus every named key:
|
||||
// a default key made later is offered too (ssh skips a missing
|
||||
// IdentityFile silently, where `-i` would warn). A linked key alone.
|
||||
const { sshDir } = paths(account);
|
||||
const all = Object.values(KEY_FILES)
|
||||
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`)
|
||||
.join("");
|
||||
const files = linked
|
||||
? [linked]
|
||||
: [...new Set([...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...keys])];
|
||||
const all = files.map((f) => ` -o IdentityFile=${shq(f)}`).join("");
|
||||
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
|
||||
}
|
||||
} else if (account.kind === "member") {
|
||||
|
||||
Reference in New Issue
Block a user