mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: type the plugin host record, move plugin strings out of core and add lint checks
This commit is contained in:
1 parent
bba06f7ff6
commit
b8bbcd2196
626 files changed
+23884
-4590
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user