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

+90 -27
View File
@@ -556,55 +556,118 @@ export interface PluginHostSummary {
}
/**
* A host record wide enough for a plugin that creates or updates hosts on the
* user's behalf (import, discovery, sync), decrypted for that user. Unlike
* PluginHostSummary this carries the fields a plugin needs to set up a
* connectable host, not just what a list view shows. Still no secret auth
* material (password, key, vault tokens): a plugin that creates a host picks
* an authType and, for "credential", a credentialId it does not need to see
* the contents of.
* A host as ctx.hosts hands it to a plugin that creates, updates or scans
* hosts on the user's behalf (import, discovery, sync), decrypted for that
* user. Every field is listed here and core builds it field by field, so a
* core column a plugin needs has to be added to this contract first. Carries
* no secret auth material (password, key, vault tokens): a plugin that
* creates a host picks an authType and, for "credential", a credentialId it
* does not need to see the contents of.
*/
export interface PluginHostRecord {
id: number;
userId: string;
/** Stable across a desktop and the server it syncs with. */
syncId: string | null;
name: string | null;
ip: string;
port: number;
username: string;
authType: string;
credentialId?: number | null;
overrideCredentialUsername?: boolean | null;
connectionType?: string | null;
credentialId: number | null;
overrideCredentialUsername: boolean;
/** "ssh", or the id of the plugin protocol a host without SSH uses. */
connectionType: string;
/** Comma-separated, as ssh_data stores them. */
tags: string | null;
folder: string | null;
jumpHosts?: unknown;
enableSsh?: boolean | null;
parentHostId: number | null;
pin: boolean;
notes: string | null;
jumpHosts: PluginHostJumpHost[];
enableSsh: boolean;
sshPort: number | null;
statusCheckEnabled: boolean;
/** Seconds between status checks; null follows the global setting. */
statusCheckInterval: number | null;
connectionOrigin: "local" | "remote" | null;
/** Keepalive, legacy algorithms, agent and environment options. */
sshOptions?: HostSshOptions | null;
createdAt?: string | null;
updatedAt?: string | null;
[key: string]: unknown;
sshOptions: HostSshOptions;
/**
* The calling plugin's own host settings, secrets redacted. Another
* plugin's settings are that plugin's to hand out, through a service.
*/
pluginSettings: Record<string, unknown>;
/** Core's last reachability check, or null before the first one. */
status: PluginHostStatusEntry | null;
/** Desktop only: kept on this device, never synced to the server. */
localOnly: boolean;
createdAt: string | null;
updatedAt: string | null;
}
export interface PluginHostJumpHost {
hostId: number;
}
/**
* Fields a plugin may set when creating a host it will own or manage. Fields
* that belong to another plugin's host settings (a protocol switch or port) are
* handed to that plugin's host import normalizer, as on a bulk import.
* The fields a plugin may set when it creates a host. A key not listed here
* is refused with an error rather than dropped. Host settings go in
* `pluginSettings`, keyed by the plugin that declares them, and are
* validated against that plugin's manifest: an undeclared key is refused, and
* settings for a plugin that is not running are skipped and logged.
*/
export type PluginHostCreateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
> & {
export interface PluginHostCreateInput {
name: string;
ip: string;
port: number;
username: string;
authType: string;
};
credentialId?: number | null;
overrideCredentialUsername?: boolean;
connectionType?: string;
/** Comma-separated or a list. */
tags?: string | string[] | null;
folder?: string | null;
pin?: boolean;
notes?: string | null;
jumpHosts?: PluginHostJumpHost[] | null;
enableSsh?: boolean;
sshPort?: number | null;
statusCheckEnabled?: boolean;
statusCheckInterval?: number | null;
forceKeyboardInteractive?: boolean;
sshOptions?: HostSshOptions | null;
/** Host settings by plugin id, the shape a Termix export carries. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
/** Fields a plugin may change on a host it already created or was granted access to. */
export type PluginHostUpdateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
>;
/** Fields a plugin may change on a host the acting user owns. */
export type PluginHostUpdateInput = Partial<PluginHostCreateInput>;
/** Every key PluginHostCreateInput and PluginHostUpdateInput accept. */
export const PLUGIN_HOST_INPUT_KEYS = [
"name",
"ip",
"port",
"username",
"authType",
"credentialId",
"overrideCredentialUsername",
"connectionType",
"tags",
"folder",
"pin",
"notes",
"jumpHosts",
"enableSsh",
"sshPort",
"statusCheckEnabled",
"statusCheckInterval",
"forceKeyboardInteractive",
"sshOptions",
"pluginSettings",
] as const satisfies readonly (keyof PluginHostCreateInput)[];
/** The same shape canAccessHost returns internally, without secrets. */
export interface PluginHostAccess {
+51 -4
View File
@@ -29,9 +29,10 @@ export type IconComponent = ComponentType<{
}>;
/**
* A host as the shell holds it. Only the identifying fields are typed; the
* rest of the record is passed through as the shell has it, so a plugin
* that needs a feature column reads it by name.
* A host as the shell hands it to a plugin. Every field is listed here and
* core builds the record field by field, so a plugin cannot come to depend on
* a core column that is not part of this contract. A plugin's own host
* settings are in `pluginSettings[<plugin id>]`.
*/
export interface PluginHostRecord {
id: string;
@@ -41,6 +42,25 @@ export interface PluginHostRecord {
username?: string;
folder?: string;
tags?: string[];
pin?: boolean;
notes?: string;
/** Stable across a desktop and the server it syncs with. */
syncId?: string | null;
/** Sub-host nesting: the host this one is organized under. */
parentHostId?: string | null;
authType?: string;
credentialId?: string | number | null;
overrideCredentialUsername?: boolean;
/** "ssh", or the id of the plugin protocol a host without SSH uses. */
connectionType?: string;
/** Desktop app: where connections to this host start from. */
connectionOrigin?: "local" | "remote" | null;
enableSsh?: boolean;
sshPort?: number;
jumpHosts?: { hostId: string | number }[];
statusCheckEnabled?: boolean;
/** Seconds between status checks; null follows the global setting. */
statusCheckInterval?: number | null;
/** Keepalive, legacy algorithms, agent and environment options. */
sshOptions?: HostSshOptions | null;
/** Each enabled plugin's host settings, secrets redacted. */
@@ -49,7 +69,34 @@ export interface PluginHostRecord {
protocolAuth?: Record<string, HostProtocolAuthSummary>;
/** Set on a Quick Connect host, which is never saved. */
quickConnectLogin?: QuickConnectLogin;
[key: string]: unknown;
/** Quick Connect only: core can save this host as it is. */
quickConnectSavable?: boolean;
/** Assigned when a host is opened in a tab; tells duplicate tabs apart. */
instanceId?: string;
/** Core's status: "online" once a login worked. */
status?: "online" | "reachable" | "offline" | "unknown";
online?: boolean;
/** Someone else owns this host and shared it with the user. */
isShared?: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
sharedExpiresAt?: string;
ownerUsername?: string;
/**
* A shared host's login per protocol ("ssh" or a plugin protocol): whether
* the owner shared theirs, and the recipient's own credential if they had
* to pick one.
*/
authOverrides?: Partial<Record<string, HostAuthOverrideSummary>>;
/** A read-only copy of a host shared with the linked account. */
sharedCopy?: boolean;
/** Desktop only: kept on this device, never synced to the server. */
localOnly?: boolean;
}
export interface HostAuthOverrideSummary {
credentialId?: number | string;
required: boolean;
ownerAuthShared: boolean;
}
/** The protocol login a Quick Connect host carries, in plain text. */
+80 -9
View File
@@ -8,7 +8,11 @@
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
*/
import { LoginMethodError, PluginCapabilityError } from "./backend.js";
import {
LoginMethodError,
PLUGIN_HOST_INPUT_KEYS,
PluginCapabilityError,
} from "./backend.js";
import type {
PluginContext,
PluginDisposables,
@@ -396,6 +400,74 @@ function noopLogger(): PluginLogger {
};
}
const HOST_INPUT_KEYS = new Set<string>(PLUGIN_HOST_INPUT_KEYS);
/** Refuses a key ctx.hosts does not take, the way core does. */
function checkHostInput(input: PluginHostUpdateInput): void {
const unknown = Object.keys(input ?? {}).filter(
(key) => !HOST_INPUT_KEYS.has(key),
);
if (unknown.length > 0) {
throw new Error(`ctx.hosts does not take ${unknown.join(", ")}`);
}
}
function emptyHostRecord(id: number, userId: string): PluginHostRecord {
return {
id,
userId,
syncId: null,
name: null,
ip: "",
port: 22,
username: "",
authType: "password",
credentialId: null,
overrideCredentialUsername: false,
connectionType: "ssh",
tags: null,
folder: null,
parentHostId: null,
pin: false,
notes: null,
jumpHosts: [],
enableSsh: true,
sshPort: null,
statusCheckEnabled: true,
statusCheckInterval: null,
connectionOrigin: null,
sshOptions: {},
pluginSettings: {},
status: null,
localOnly: false,
createdAt: null,
updatedAt: null,
};
}
function applyHostInput(
record: PluginHostRecord,
input: PluginHostUpdateInput,
): PluginHostRecord {
const {
tags,
pluginSettings: _settings,
forceKeyboardInteractive: _fki,
...rest
} = input;
const next: PluginHostRecord = { ...record };
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined)
(next as unknown as Record<string, unknown>)[key] = value;
}
if (tags !== undefined) {
next.tags = Array.isArray(tags) ? tags.join(",") : tags;
}
if (next.jumpHosts === null) next.jumpHosts = [];
if (next.sshOptions === null) next.sshOptions = {};
return next;
}
/**
* A context double good enough to call activate() against in a unit test.
* Nothing here is capability-checked: a test asserting a capability gate
@@ -823,14 +895,12 @@ export function createFakeContext(
create: async (
host: PluginHostCreateInput,
): Promise<PluginHostRecord> => {
checkHostInput(host);
const id = nextHostId++;
const record: PluginHostRecord = {
tags: null,
folder: null,
...host,
id,
userId: actor ?? "unknown",
};
const record = applyHostInput(
emptyHostRecord(id, actor ?? "unknown"),
host,
);
hostRecordsById.set(id, record);
return record;
},
@@ -838,9 +908,10 @@ export function createFakeContext(
hostId: number,
patch: PluginHostUpdateInput,
): Promise<PluginHostRecord | null> => {
checkHostInput(patch);
const existing = hostRecordsById.get(hostId);
if (!existing) return null;
const updated = { ...existing, ...patch };
const updated = applyHostInput(existing, patch);
hostRecordsById.set(hostId, updated);
return updated;
},