Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/plugins/ssh-config/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export const messages: Record<PluginLocale, Record<string,string>> = {
"keyImported": "SSH key imported: {{name}}",
"identityCreated": "SSH identity created: {{name}}",
"hostAdded": "SSH host added: {{name}}",
"importFromMachine": "Import from this computer",
"importFromMachineDesc": "Copy hosts from this computer's ~/.ssh/config, and the keys they use, into this account's Personal vault, and keep them in sync. Each account has its own setting.",
"consentPrompt_one": "Import {{count}} host from this computer's ~/.ssh/config into this account's Personal vault? Its SSH key is imported too.",
"consentPrompt_other": "Import {{count}} hosts from this computer's ~/.ssh/config into this account's Personal vault? Their SSH keys are imported too.",
"consentImport": "Import",
"consentSkip": "Don't import",
},
fr: {
"settingsLabel": "Synchronisation de la config SSH",
Expand All @@ -32,6 +38,13 @@ export const messages: Record<PluginLocale, Record<string,string>> = {
"keyImported": "Clé SSH importée : {{name}}",
"identityCreated": "Identité SSH créée : {{name}}",
"hostAdded": "Hôte SSH ajouté : {{name}}",
"importFromMachine": "Importer depuis cet ordinateur",
"importFromMachineDesc": "Copier les hôtes du ~/.ssh/config de cet ordinateur, ainsi que leurs clés, dans le coffre Personnel de ce compte, et les garder synchronisés. Chaque compte a son propre réglage.",
"consentPrompt_one": "Importer {{count}} hôte du ~/.ssh/config de cet ordinateur dans le coffre Personnel de ce compte ? Sa clé SSH sera aussi importée.",
"consentPrompt_many": "Importer {{count}} hôtes du ~/.ssh/config de cet ordinateur dans le coffre Personnel de ce compte ? Leurs clés SSH seront aussi importées.",
"consentPrompt_other": "Importer {{count}} hôtes du ~/.ssh/config de cet ordinateur dans le coffre Personnel de ce compte ? Leurs clés SSH seront aussi importées.",
"consentImport": "Importer",
"consentSkip": "Ne pas importer",
},
ru: {
"settingsLabel": "Синхронизация конфигурации SSH",
Expand All @@ -48,6 +61,14 @@ export const messages: Record<PluginLocale, Record<string,string>> = {
"keyImported": "SSH-ключ импортирован: {{name}}",
"identityCreated": "Учётные данные SSH созданы: {{name}}",
"hostAdded": "SSH-хост добавлен: {{name}}",
"importFromMachine": "Импорт с этого компьютера",
"importFromMachineDesc": "Копировать хосты из ~/.ssh/config этого компьютера вместе с их ключами в личное хранилище этого аккаунта и поддерживать их в актуальном состоянии. У каждого аккаунта своя настройка.",
"consentPrompt_one": "Импортировать {{count}} хост из ~/.ssh/config этого компьютера в личное хранилище этого аккаунта? Его SSH-ключ тоже будет импортирован.",
"consentPrompt_few": "Импортировать {{count}} хоста из ~/.ssh/config этого компьютера в личное хранилище этого аккаунта? Их SSH-ключи тоже будут импортированы.",
"consentPrompt_many": "Импортировать {{count}} хостов из ~/.ssh/config этого компьютера в личное хранилище этого аккаунта? Их SSH-ключи тоже будут импортированы.",
"consentPrompt_other": "Импортировать {{count}} хоста из ~/.ssh/config этого компьютера в личное хранилище этого аккаунта? Их SSH-ключи тоже будут импортированы.",
"consentImport": "Импортировать",
"consentSkip": "Не импортировать",
},
zh: {
"settingsLabel": "SSH 配置同步",
Expand All @@ -64,6 +85,11 @@ export const messages: Record<PluginLocale, Record<string,string>> = {
"keyImported": "已导入 SSH 密钥:{{name}}",
"identityCreated": "已创建 SSH 身份:{{name}}",
"hostAdded": "已添加 SSH 主机:{{name}}",
"importFromMachine": "从此电脑导入",
"importFromMachineDesc": "将此电脑 ~/.ssh/config 中的主机及其使用的密钥复制到此账户的个人保险库,并保持同步。每个账户单独设置。",
"consentPrompt_other": "要将此电脑 ~/.ssh/config 中的 {{count}} 个主机导入此账户的个人保险库吗?其 SSH 密钥也会一并导入。",
"consentImport": "导入",
"consentSkip": "不导入",
},
tr: {
"settingsLabel": "SSH yapılandırma eşitlemesi",
Expand All @@ -80,6 +106,12 @@ export const messages: Record<PluginLocale, Record<string,string>> = {
"keyImported": "SSH anahtarı içe aktarıldı: {{name}}",
"identityCreated": "SSH kimliği oluşturuldu: {{name}}",
"hostAdded": "SSH sunucusu eklendi: {{name}}",
"importFromMachine": "Bu bilgisayardan içe aktar",
"importFromMachineDesc": "Bu bilgisayarın ~/.ssh/config dosyasındaki sunucuları ve kullandıkları anahtarları bu hesabın Kişisel kasasına kopyalayın ve eşitli tutun. Her hesabın kendi ayarı vardır.",
"consentPrompt_one": "Bu bilgisayarın ~/.ssh/config dosyasındaki {{count}} sunucu bu hesabın Kişisel kasasına aktarılsın mı? SSH anahtarı da içe aktarılır.",
"consentPrompt_other": "Bu bilgisayarın ~/.ssh/config dosyasındaki {{count}} sunucu bu hesabın Kişisel kasasına aktarılsın mı? SSH anahtarları da içe aktarılır.",
"consentImport": "İçe aktar",
"consentSkip": "İçe aktarma",
},
cs: {
"settingsLabel": "Synchronizace SSH config",
Expand All @@ -96,5 +128,13 @@ export const messages: Record<PluginLocale, Record<string,string>> = {
"keyImported": "SSH klíč importován: {{name}}",
"identityCreated": "SSH identita vytvořena: {{name}}",
"hostAdded": "SSH hostitel přidán: {{name}}",
"importFromMachine": "Importovat z tohoto počítače",
"importFromMachineDesc": "Zkopírovat hostitele z ~/.ssh/config tohoto počítače i s jejich klíči do osobního trezoru tohoto účtu a udržovat je synchronizované. Každý účet má vlastní nastavení.",
"consentPrompt_one": "Importovat {{count}} hostitele z ~/.ssh/config tohoto počítače do osobního trezoru tohoto účtu? Importuje se i jeho SSH klíč.",
"consentPrompt_few": "Importovat {{count}} hostitele z ~/.ssh/config tohoto počítače do osobního trezoru tohoto účtu? Importují se i jejich SSH klíče.",
"consentPrompt_many": "Importovat {{count}} hostitele z ~/.ssh/config tohoto počítače do osobního trezoru tohoto účtu? Importují se i jejich SSH klíče.",
"consentPrompt_other": "Importovat {{count}} hostitelů z ~/.ssh/config tohoto počítače do osobního trezoru tohoto účtu? Importují se i jejich SSH klíče.",
"consentImport": "Importovat",
"consentSkip": "Neimportovat",
},
};
153 changes: 100 additions & 53 deletions src/plugins/ssh-config/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { useT } from "@voltius/ui";
import type { PluginAPI, PluginConnectionInput, PluginManifest, PluginRegisterFn } from "@/plugins/api";
import type { BannerHandle, PluginAPI, PluginConnectionInput, PluginManifest, PluginRegisterFn } from "@/plugins/api";
import manifestJson from "./manifest.json";
import { messages } from "./i18n";

Expand Down Expand Up @@ -467,25 +467,50 @@ const ADOPT_UNTAGGED_ENABLED_KEY = "adopt_untagged_enabled";
const DEFAULT_ADOPT_UNTAGGED_ENABLED = true;
const RESTART_EVENT = "ssh-config:restart-watcher";
const SYNC_NOW_EVENT = "ssh-config:sync-now";
const IMPORT_CONSENT_KEY = "import_consent";
const CONSENT_EVENT = "ssh-config:consent";

type ImportConsent = "granted" | "declined";

// Plugin storage is restored per account on sign-in, so each account answers for itself.
async function readImportConsent(api: PluginAPI): Promise<ImportConsent | null> {
const stored = await api.storage.get<ImportConsent>(IMPORT_CONSENT_KEY);
if (stored) return stored;
const aliasMap = await api.storage.get<AliasMap>(ALIAS_MAP_KEY);
if (!aliasMap || Object.keys(aliasMap).length === 0) return null;
await api.storage.set(IMPORT_CONSENT_KEY, "granted");
return "granted";
}

export async function setImportConsent(api: PluginAPI, granted: boolean): Promise<void> {
await api.storage.set(IMPORT_CONSENT_KEY, granted ? "granted" : "declined");
api.events.emit(CONSENT_EVENT, granted);
}

const importAllowed = async (api: PluginAPI) => (await readImportConsent(api)) === "granted";

function createSettingsComponent(api: PluginAPI): React.FC {
return function SshConfigSettings() {
const t = useT(api);
const [intervalMs, setIntervalMs] = useState<number>(DEFAULT_POLL_INTERVAL);
const [notificationsEnabled, setNotificationsEnabled] = useState<boolean>(DEFAULT_NOTIFICATIONS_ENABLED);
const [adoptEnabled, setAdoptEnabled] = useState<boolean>(DEFAULT_ADOPT_UNTAGGED_ENABLED);
const [importEnabled, setImportEnabled] = useState(false);
const [syncing, setSyncing] = useState(false);

useEffect(() => {
void Promise.all([
api.storage.get<number>(POLL_INTERVAL_KEY),
api.storage.get<boolean>(NOTIFICATIONS_ENABLED_KEY),
api.storage.get<boolean>(ADOPT_UNTAGGED_ENABLED_KEY),
]).then(([interval, notify, adopt]) => {
importAllowed(api),
]).then(([interval, notify, adopt, allowed]) => {
if (interval != null) setIntervalMs(interval);
if (notify != null) setNotificationsEnabled(notify);
if (adopt != null) setAdoptEnabled(adopt);
setImportEnabled(allowed);
});
return api.events.on(CONSENT_EVENT, (granted) => setImportEnabled(granted === true));
}, []);

const handleIntervalChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -507,6 +532,8 @@ function createSettingsComponent(api: PluginAPI): React.FC {
void api.storage.set(ADOPT_UNTAGGED_ENABLED_KEY, next);
};

const handleImportToggle = () => void setImportConsent(api, !importEnabled);

const handleSyncNow = () => {
setSyncing(true);
api.events.emit(SYNC_NOW_EVENT);
Expand Down Expand Up @@ -546,14 +573,31 @@ function createSettingsComponent(api: PluginAPI): React.FC {
background: "white",
transition: "left 0.15s",
});
const syncDisabled = syncing || !importEnabled;
const syncBtnStyle = {
...inputStyle,
padding: "4px 12px",
borderRadius: 8,
fontSize: 12,
cursor: syncing ? "default" : "pointer",
opacity: syncing ? 0.6 : 1,
cursor: syncDisabled ? "default" : "pointer",
opacity: syncDisabled ? 0.6 : 1,
};
const toggleRow = (label: string, desc: string, on: boolean, onToggle: () => void) =>
React.createElement(
"div",
{ className: "flex items-center justify-between" },
React.createElement(
"div",
null,
React.createElement("p", { className: "text-sm font-medium", style: labelStyle }, label),
React.createElement("p", { className: "text-xs mt-0.5", style: dimStyle }, desc)
),
React.createElement(
"div",
{ style: toggleTrack(on), onClick: onToggle },
React.createElement("div", { style: toggleThumb(on) })
)
);

return React.createElement(
"div",
Expand All @@ -569,6 +613,8 @@ function createSettingsComponent(api: PluginAPI): React.FC {
React.createElement(
"div",
{ className: "rounded-xl p-4", style: cardStyle },
toggleRow(t("importFromMachine"), t("importFromMachineDesc"), importEnabled, handleImportToggle),
divider,
React.createElement(
"div",
{ className: "flex items-center justify-between" },
Expand Down Expand Up @@ -597,51 +643,15 @@ function createSettingsComponent(api: PluginAPI): React.FC {
React.createElement("span", { className: "text-xs", style: dimStyle }, t("seconds")),
React.createElement(
"button",
{ onClick: handleSyncNow, disabled: syncing, style: syncBtnStyle },
{ onClick: handleSyncNow, disabled: syncDisabled, style: syncBtnStyle },
syncing ? t("syncing") : t("syncNow")
)
)
),
divider,
React.createElement(
"div",
{ className: "flex items-center justify-between" },
React.createElement(
"div",
null,
React.createElement("p", { className: "text-sm font-medium", style: labelStyle }, t("notifications")),
React.createElement(
"p",
{ className: "text-xs mt-0.5", style: dimStyle },
t("notificationsDesc")
)
),
React.createElement(
"div",
{ style: toggleTrack(notificationsEnabled), onClick: handleNotificationsToggle },
React.createElement("div", { style: toggleThumb(notificationsEnabled) })
)
),
toggleRow(t("notifications"), t("notificationsDesc"), notificationsEnabled, handleNotificationsToggle),
divider,
React.createElement(
"div",
{ className: "flex items-center justify-between" },
React.createElement(
"div",
null,
React.createElement("p", { className: "text-sm font-medium", style: labelStyle }, t("adopt")),
React.createElement(
"p",
{ className: "text-xs mt-0.5", style: dimStyle },
t("adoptDesc")
)
),
React.createElement(
"div",
{ style: toggleTrack(adoptEnabled), onClick: handleAdoptToggle },
React.createElement("div", { style: toggleThumb(adoptEnabled) })
)
)
toggleRow(t("adopt"), t("adoptDesc"), adoptEnabled, handleAdoptToggle)
)
)
);
Expand All @@ -668,7 +678,8 @@ export const register: PluginRegisterFn = (api) => {
if (!api.isActive()) return () => {};

let stopWatch: (() => void) | null = null;
// Both deferred starts below can resolve after cleanup; they must not revive a disabled plugin.
let prompt: BannerHandle | null = null;
// The deferred starts below can resolve after cleanup; they must not revive a disabled plugin.
let disposed = false;

const runSync = (trigger: SyncTrigger) =>
Expand Down Expand Up @@ -696,25 +707,56 @@ export const register: PluginRegisterFn = (api) => {
api.log.info(`watcher started every ${intervalMs}ms (live watchers: ${liveWatchers})`);
};

// Defer initial sync until login-time server sync has landed so the dedup
// lists (connections/keys/identities) reflect post-merge state. For local
// users the promise resolves immediately; for cloud users it waits for
// syncOnLogin to finish (vault_reset on logout wipes the config dir).
api.lifecycle.waitForLoginSync().then(() => {
if (!disposed) void runSync("initial");
const withdrawPrompt = () => {
prompt?.dismiss();
prompt = null;
};

const startImporting = async (trigger: SyncTrigger) => {
const interval = (await api.storage.get<number>(POLL_INTERVAL_KEY)) ?? DEFAULT_POLL_INTERVAL;
if (disposed) return;
startWatcher(interval);
void runSync(trigger);
};

const askConsent = async () => {
if (!(await api.fs.exists(SSH_CONFIG_PATH))) return;
const count = parseSshConfig(await api.fs.readText(SSH_CONFIG_PATH)).length;
if (count === 0 || disposed) return;
prompt = api.notifications.banner(api.i18n.t("consentPrompt", { count }), {
actions: [
{ label: api.i18n.t("consentImport"), onClick: () => void setImportConsent(api, true) },
{ label: api.i18n.t("consentSkip"), onClick: () => void setImportConsent(api, false) },
],
});
};

// Wait for login-time server sync: it restores this account's consent and the
// post-merge connections/keys/identities the dedup reads.
api.lifecycle.waitForLoginSync().then(async () => {
if (disposed) return;
const consent = await readImportConsent(api);
if (disposed) return;
if (consent === "granted") await startImporting("initial");
else if (consent === null) await askConsent();
});

api.storage.get<number>(POLL_INTERVAL_KEY).then((stored) => {
startWatcher(stored ?? DEFAULT_POLL_INTERVAL);
const offConsent = api.events.on(CONSENT_EVENT, (granted) => {
withdrawPrompt();
if (granted === true) void startImporting("manual");
else stopWatcher();
});

const offEvent = api.events.on(RESTART_EVENT, (data) => {
if (!stopWatch) return;
const newInterval = typeof data === "number" ? data : DEFAULT_POLL_INTERVAL;
api.log.info(`Poll interval changed to ${newInterval}ms`);
startWatcher(newInterval);
});

const offSyncNow = api.events.on(SYNC_NOW_EVENT, () => void runSync("manual"));
const offSyncNow = api.events.on(SYNC_NOW_EVENT, async () => {
if (await importAllowed(api)) void runSync("manual");
});

const offMcp = api.mcp.registerTools([
{
Expand All @@ -726,6 +768,9 @@ export const register: PluginRegisterFn = (api) => {
"deleted. Returns how many hosts were seen.",
inputSchema: { type: "object", properties: {} },
execute: async () => {
if (!(await importAllowed(api))) {
return "Importing ~/.ssh/config is turned off for this account. The user can turn it on in SSH Config Sync settings.";
}
await sync(api, "mcp");
return "synced";
},
Expand All @@ -735,6 +780,8 @@ export const register: PluginRegisterFn = (api) => {
return () => {
disposed = true;
stopWatcher();
withdrawPrompt();
offConsent();
offEvent();
offSyncNow();
offMcp();
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/ssh-config/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "plugin-ssh-config",
"name": "SSH Config Sync",
"version": "1.2.1",
"version": "1.3.0",
"minAppVersion": "0.19.0",
"description": "Auto-syncs hosts from ~/.ssh/config. Connections are tagged 'ssh-config'.",
"permissions": [
Expand Down
Loading
Loading