feat: type the plugin host record, move plugin strings out of core and add lint checks

This commit is contained in:
LukeGus committed 2026-09-29 13:08:39 -05:00
1 parent bba06f7ff6
commit b8bbcd2196
626 files changed
+23884 -4590

No files matched your search

+6 -6
View File
@@ -49,8 +49,8 @@ export function proxmoxSourceKey(source: {
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
}
export interface ImportedGuest {
host: Record<string, unknown>;
export interface ImportedGuest<H = Record<string, unknown>> {
host: H;
config: Record<string, unknown>;
source: ProxmoxSource;
}
@@ -60,13 +60,13 @@ export interface ImportedGuest {
* source lives in each guest's proxmoxConfig host setting, not on the host
* row, so the caller passes both.
*/
export function indexImportedGuests(
hosts: Record<string, unknown>[],
export function indexImportedGuests<H extends { id: unknown }>(
hosts: H[],
configs: Array<{ hostId: number; value: unknown }>,
sourceHostId: number,
): Map<string, ImportedGuest> {
): Map<string, ImportedGuest<H>> {
const configById = new Map(configs.map((c) => [c.hostId, c.value]));
const out = new Map<string, ImportedGuest>();
const out = new Map<string, ImportedGuest<H>>();
for (const host of hosts) {
const config = parseJsonObject(configById.get(Number(host.id)));
const source = getProxmoxSource(config);
@@ -10,8 +10,3 @@ export function parseProxmoxJumpHosts(raw: unknown): unknown[] | null {
return null;
}
}
export function serializeProxmoxJumpHosts(raw: unknown): string | null {
const parsed = parseProxmoxJumpHosts(raw);
return parsed ? JSON.stringify(parsed) : null;
}
+56 -76
View File
@@ -3,16 +3,14 @@ import type { Client as SSHClient } from "ssh2";
import { execElevated } from "@termix/plugin-sdk/host-commands";
import type {
PluginHostCreateInput,
PluginHostRecord,
PluginHostJumpHost,
PluginHostUpdateInput,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { pluginCtx } from "./plugin-ctx.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "./proxmox-jump-hosts.js";
import { parseProxmoxJumpHosts } from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "./proxmox-shared.js";
import {
indexImportedGuests,
@@ -190,7 +188,7 @@ type ProxmoxGuest = {
};
/** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */
type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string };
type PluginSshHostWithSudo = PluginSshHost & { sudoPassword?: string };
type ProxmoxSyncResult = {
created: number;
@@ -215,6 +213,22 @@ function guestTags(guest: ProxmoxGuest): string[] {
];
}
/**
* The switches another plugin keeps for an imported guest. An entry for a
* plugin that is not running is skipped by core.
*/
export function guestPluginSettings(
connectionType: "ssh" | "rdp",
enableDocker: boolean,
): Record<string, Record<string, unknown>> {
return {
...(connectionType === "rdp"
? { "remote-desktop": { enableRdp: true, rdpPort: 3389 } }
: {}),
...(enableDocker ? { docker: { enableDocker: true } } : {}),
};
}
function mergeTags(
existing: unknown,
additions: string[],
@@ -261,7 +275,7 @@ async function discoverProxmoxGuestsForHost(
timeoutMs: 35000,
overrides: { tryKeyboard: false, readyTimeout: 30000 },
});
const hostWithSudo = host as PluginSshHostWithSudo;
const hostWithSudo: PluginSshHostWithSudo = host;
const hostCredentialId = (hostWithSudo.credentialId as number | null) ?? null;
try {
@@ -498,7 +512,7 @@ async function syncProxmoxHost(
try {
const discovery = await discoverProxmoxGuestsForHost(userId, sourceHostId);
const sourceHostName = discovery.host.name || "Proxmox";
const sourceHostName = String(discovery.host.name || "Proxmox");
const defaultCredentialId =
discovery.config.defaultCredentialId ?? discovery.credentialId ?? null;
const importAuth = resolveProxmoxImportAuth(
@@ -507,12 +521,8 @@ async function syncProxmoxHost(
);
const now = new Date().toISOString();
const existingHosts = (await ctx.hosts.listOwned()) as unknown as Record<
string,
unknown
>[];
const existingBySource = indexImportedGuests(
existingHosts,
await ctx.hosts.listOwned(),
await ctx.settings.listHostValues("proxmoxConfig"),
sourceHostId,
);
@@ -560,7 +570,7 @@ async function syncProxmoxHost(
: connectionType === "rdp"
? ""
: "root";
const update: Record<string, unknown> = {
const update: PluginHostUpdateInput = {
name: guest.name,
ip: guest.ip || existing?.ip || "0.0.0.0",
port,
@@ -568,7 +578,7 @@ async function syncProxmoxHost(
connectionType,
folder: existing?.folder || sourceHostName,
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
updatedAt: now,
pluginSettings: { [ctx.pluginId]: { proxmoxConfig } },
};
if (existing) {
@@ -576,69 +586,46 @@ async function syncProxmoxHost(
update.credentialId = importAuth.credentialId;
update.overrideCredentialUsername = false;
}
await ctx.hosts.update(
existing.id as number,
update as PluginHostUpdateInput,
);
await ctx.settings.setHost(
existing.id as number,
"proxmoxConfig",
proxmoxConfig,
);
await ctx.hosts.update(existing.id, update);
result.updated++;
continue;
}
Object.assign(update, {
enableDocker: guest.enableDocker,
enableSsh: connectionType === "ssh",
enableRdp: connectionType === "rdp",
});
const created = await ctx.hosts.create({
await ctx.hosts.create({
...update,
createdAt: now,
name: guest.name,
ip: update.ip!,
port,
username,
enableSsh: connectionType === "ssh",
pin: false,
authType: connectionType === "rdp" ? "password" : importAuth.authType,
credentialId: connectionType === "ssh" ? importAuth.credentialId : null,
overrideCredentialUsername: importAuth.overrideCredentialUsername,
password: null,
key: null,
keyPassword: null,
keyType: null,
rdpPort: connectionType === "rdp" ? 3389 : null,
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
quickActions: null,
forceKeyboardInteractive: "false",
useSocks5: 0,
socks5Host: null,
socks5Port: null,
socks5Username: null,
socks5Password: null,
socks5ProxyChain: null,
portKnockSequence: null,
} as unknown as PluginHostCreateInput);
await ctx.settings.setHost(
Number(created.id),
"proxmoxConfig",
proxmoxConfig,
);
overrideCredentialUsername: !!importAuth.overrideCredentialUsername,
jumpHosts: (parseProxmoxJumpHosts(discovery.jumpHosts) ??
[]) as PluginHostJumpHost[],
forceKeyboardInteractive: false,
pluginSettings: {
...update.pluginSettings,
...guestPluginSettings(connectionType, guest.enableDocker),
},
});
result.created++;
}
if (discovery.config.markMissingGuests) {
for (const [key, { host, config, source }] of existingBySource) {
if (seen.has(key)) continue;
await ctx.hosts.update(
host.id as number,
{
tags: mergeTags(host.tags, ["proxmox-missing"]),
updatedAt: now,
} as PluginHostUpdateInput,
);
await ctx.settings.setHost(host.id as number, "proxmoxConfig", {
...config,
source: { ...source, missingSince: source.missingSince || now },
await ctx.hosts.update(host.id, {
tags: mergeTags(host.tags, ["proxmox-missing"]),
pluginSettings: {
[ctx.pluginId]: {
proxmoxConfig: {
...config,
source: { ...source, missingSince: source.missingSince || now },
},
},
},
});
result.markedMissing++;
}
@@ -984,22 +971,15 @@ router.post("/import", async (req, res) => {
return res.status(400).json({ error: "No hosts to import" });
}
const result = { success: 0, failed: 0, errors: [] as string[] };
for (const entry of hosts as Array<Record<string, unknown>>) {
const { pluginSettings, ...input } = entry;
for (const entry of hosts as PluginHostCreateInput[]) {
try {
const created = await ctx.hosts.create(
input as unknown as PluginHostCreateInput,
);
const own = (pluginSettings as Record<string, unknown> | undefined)?.[
ctx.pluginId
] as Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(own ?? {})) {
await ctx.settings.setHost(Number(created.id), key, value);
}
// Core refuses any field it does not take, and any host setting its
// plugin does not declare.
await ctx.hosts.create(entry);
result.success++;
} catch (err: unknown) {
result.failed++;
result.errors.push(`${String(input.name)}: ${getErrorMessage(err)}`);
result.errors.push(`${String(entry?.name)}: ${getErrorMessage(err)}`);
}
}
return res.json(result);
@@ -158,8 +158,6 @@ export function ProxmoxDiscoverDialog({
jumpHosts: discoveredJumpHosts ?? undefined,
...importAuth,
enableSsh: g.connectionType !== "rdp",
enableRdp: g.connectionType === "rdp",
enableDocker: g.enableDocker,
connectionType: g.connectionType,
tags: [
"proxmox",
@@ -168,8 +166,13 @@ export function ProxmoxDiscoverDialog({
g.type === "lxc" ? `ct-${g.vmid}` : `vm-${g.vmid}`,
...(g.enableDocker ? ["docker"] : []),
],
// This plugin's host settings travel with the import like an export's.
// Host settings travel with the import like an export's, keyed by
// the plugin that keeps them.
pluginSettings: {
...(g.connectionType === "rdp"
? { "remote-desktop": { enableRdp: true, rdpPort: 3389 } }
: {}),
...(g.enableDocker ? { docker: { enableDocker: true } } : {}),
proxmox: {
proxmoxConfig: {
source: {
+11 -4
View File
@@ -92,6 +92,13 @@ function ProxmoxStatsStandalone({ hostId }: StandaloneViewProps) {
return <ProxmoxStatsApp hostId={hostId} />;
}
/** This plugin's host settings, as they arrive on the host record. */
export function proxmoxSettings(
host: Pick<PluginHostRecord, "pluginSettings">,
): Record<string, unknown> {
return host.pluginSettings?.proxmox ?? {};
}
export function activate(app: TermixApp): void {
setProxmoxApp(app);
app.onDispose(() => setProxmoxApp(null));
@@ -136,7 +143,7 @@ export function activate(app: TermixApp): void {
return (
<DropdownMenuItem
onClick={() => openDiscover({})}
disabled={!hosts.some((host) => host.enableProxmox)}
disabled={!hosts.some((host) => proxmoxSettings(host).enableProxmox)}
>
<Server className="size-3.5 mr-2" />
{t("hosts.proxmoxImportTitle")}
@@ -166,9 +173,9 @@ export function activate(app: TermixApp): void {
icon: Boxes,
kind: "open",
order: 90,
when: (host) => !!host.enableProxmox,
when: (host) => !!proxmoxSettings(host).enableProxmox,
run: (host) => {
const config = host.proxmoxConfig as
const config = proxmoxSettings(host).proxmoxConfig as
| { defaultCredentialId?: number | null; defaultAuthType?: string }
| undefined;
openDiscover({
@@ -205,6 +212,6 @@ export function activate(app: TermixApp): void {
order: 60,
tabType: "proxmox-stats",
copyUrlView: "proxmox-stats",
when: (host) => host.enableProxmoxStats === true,
when: (host) => proxmoxSettings(host).enableProxmoxStats === true,
});
}