Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
954 lines
39 KiB
TypeScript
954 lines
39 KiB
TypeScript
import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models';
|
||
import { sanitizeHost } from './host';
|
||
import type { HostOsSelection } from './models/connection';
|
||
import {
|
||
findIntroducedVaultJumpGraphIssue,
|
||
findVaultGroupConfigJumpReference,
|
||
} from './vaultJumpGraph';
|
||
|
||
const DEFAULT_SSH_PORT = 22;
|
||
const DEFAULT_TELNET_PORT = 23;
|
||
const UNSAFE_SSH_CONFIG_VALUE = /[\r\n\0]/;
|
||
const HOSTNAME_WHITESPACE = /\s/;
|
||
const UNSAFE_SSH_JUMP_HOSTNAME = /[\s,@#]/;
|
||
const UNSAFE_SSH_JUMP_USERNAME = /[\s,#]/;
|
||
|
||
const isSafeSshConfigValue = (value: string): boolean =>
|
||
!UNSAFE_SSH_CONFIG_VALUE.test(value);
|
||
|
||
const isSafeSshJumpHostname = (value: string): boolean =>
|
||
!value.startsWith('-') && !UNSAFE_SSH_JUMP_HOSTNAME.test(value);
|
||
|
||
const isSafeSshJumpUsername = (value: string): boolean =>
|
||
!value.startsWith('-') && !UNSAFE_SSH_JUMP_USERNAME.test(value);
|
||
|
||
export type VaultHostDraftProtocol = Exclude<HostProtocol, 'mosh' | 'et' | 'serial'>;
|
||
|
||
export interface VaultHostDraft {
|
||
label?: unknown;
|
||
name?: unknown;
|
||
hostname?: unknown;
|
||
host?: unknown;
|
||
ip?: unknown;
|
||
port?: unknown;
|
||
username?: unknown;
|
||
password?: unknown;
|
||
savePassword?: unknown;
|
||
keyPath?: unknown;
|
||
keypath?: unknown;
|
||
passphrase?: unknown;
|
||
group?: unknown;
|
||
tags?: unknown;
|
||
notes?: unknown;
|
||
protocol?: unknown;
|
||
os?: unknown;
|
||
}
|
||
|
||
export interface VaultHostUpdatePatch extends VaultHostDraft {
|
||
identityId?: unknown;
|
||
jumpHostIds?: unknown;
|
||
proxyProfileId?: unknown;
|
||
startupCommand?: unknown;
|
||
startupCommandRunMode?: unknown;
|
||
environmentVariables?: unknown;
|
||
moshEnabled?: unknown;
|
||
moshServerPath?: unknown;
|
||
etEnabled?: unknown;
|
||
etPort?: unknown;
|
||
serialConfig?: unknown;
|
||
}
|
||
|
||
export interface VaultHostUpdateOptions {
|
||
resolveEffectiveHost?: (host: Host) => Host;
|
||
groupConfigs?: GroupConfig[];
|
||
managedSources?: ManagedSource[];
|
||
identities?: Identity[];
|
||
proxyProfiles?: ProxyProfile[];
|
||
}
|
||
|
||
export interface VaultHostCreateIssue {
|
||
index: number;
|
||
error: string;
|
||
}
|
||
|
||
export interface VaultHostKeyPassphrase {
|
||
hostId: string;
|
||
keyPath: string;
|
||
passphrase: string;
|
||
}
|
||
|
||
const normalizeGroupPath = (raw: unknown): string | undefined => {
|
||
if (typeof raw !== 'string') return undefined;
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return undefined;
|
||
const parts = trimmed.replace(/\\/g, '/').split('/').map((part) => part.trim()).filter(Boolean);
|
||
return parts.length ? parts.join('/') : undefined;
|
||
};
|
||
|
||
const normalizeProtocol = (raw: unknown): VaultHostDraftProtocol | undefined => {
|
||
if (typeof raw !== 'string') return undefined;
|
||
const value = raw.trim().toLowerCase();
|
||
if (value === 'ssh' || value === 'ssh2') return 'ssh';
|
||
if (value === 'telnet') return 'telnet';
|
||
if (value === 'local') return 'local';
|
||
return undefined;
|
||
};
|
||
|
||
const normalizeOs = (raw: unknown): HostOsSelection | undefined => {
|
||
if (typeof raw !== 'string') return undefined;
|
||
const value = raw.trim().toLowerCase();
|
||
if (value === 'auto' || value === 'freebsd' || value === 'unknown') return value;
|
||
if (value === 'linux') return 'linux';
|
||
if (value === 'windows' || value === 'win') return 'windows';
|
||
if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos';
|
||
return undefined;
|
||
};
|
||
|
||
const parsePort = (raw: unknown): number | undefined => {
|
||
if (typeof raw === 'number') {
|
||
return Number.isInteger(raw) && raw >= 1 && raw <= 65535 ? raw : undefined;
|
||
}
|
||
if (typeof raw !== 'string') return undefined;
|
||
const trimmed = raw.trim();
|
||
if (!/^\d+$/u.test(trimmed)) return undefined;
|
||
const port = Number(trimmed);
|
||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
|
||
};
|
||
|
||
const defaultPortForProtocol = (protocol: HostProtocol | undefined): number =>
|
||
protocol === 'telnet' ? DEFAULT_TELNET_PORT : DEFAULT_SSH_PORT;
|
||
|
||
const supportsSshJump = (host: Host): boolean =>
|
||
host.protocol === undefined || host.protocol === 'ssh';
|
||
|
||
const parseBoolean = (raw: unknown): boolean | undefined => {
|
||
if (typeof raw === 'boolean') return raw;
|
||
if (typeof raw !== 'string') return undefined;
|
||
const normalized = raw.trim().toLowerCase();
|
||
if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true;
|
||
if (normalized === 'false' || normalized === '0' || normalized === 'no') return false;
|
||
return undefined;
|
||
};
|
||
|
||
const normalizeTags = (values: unknown[]): string[] => Array.from(
|
||
new Set(values.map((entry) => String(entry).trim()).filter(Boolean)),
|
||
);
|
||
|
||
const parseTags = (
|
||
raw: unknown,
|
||
): { ok: true; tags: string[] } | { ok: false; error: string } => {
|
||
if (raw === undefined || raw === null || raw === '') return { ok: true, tags: [] };
|
||
if (Array.isArray(raw)) return { ok: true, tags: normalizeTags(raw) };
|
||
if (typeof raw !== 'string') {
|
||
return { ok: false, error: 'tags must be an array or comma-separated string.' };
|
||
}
|
||
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return { ok: true, tags: [] };
|
||
if (trimmed.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmed) as unknown;
|
||
if (!Array.isArray(parsed)) {
|
||
return { ok: false, error: 'tags JSON must be an array.' };
|
||
}
|
||
return { ok: true, tags: normalizeTags(parsed) };
|
||
} catch {
|
||
return { ok: false, error: 'tags must be a valid JSON array.' };
|
||
}
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
tags: normalizeTags(trimmed.split(/[,;,]/g)),
|
||
};
|
||
};
|
||
|
||
const hasOwn = (value: object, key: string): boolean =>
|
||
Object.prototype.hasOwnProperty.call(value, key);
|
||
|
||
const firstProvided = (
|
||
value: Record<string, unknown>,
|
||
keys: string[],
|
||
): { provided: boolean; value?: unknown } => {
|
||
for (const key of keys) {
|
||
if (hasOwn(value, key)) return { provided: true, value: value[key] };
|
||
}
|
||
return { provided: false };
|
||
};
|
||
|
||
const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
|
||
const raw = draft.keyPath ?? draft.keypath;
|
||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||
};
|
||
|
||
/** Endpoint-only identity (protocol/host/port/user). Used for managed ssh_config matching. */
|
||
export const buildVaultHostEndpointKey = (
|
||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
|
||
): string =>
|
||
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
|
||
|
||
/**
|
||
* Session identity for vault import/create dedupe.
|
||
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
|
||
*/
|
||
export const buildVaultHostMergeKey = (
|
||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
|
||
): string => {
|
||
// Keep normalized group spelling (do not case-fold). Vault group paths are
|
||
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
|
||
const group = normalizeGroupPath(host.group) ?? '';
|
||
return `${buildVaultHostEndpointKey(host)}|${group}`;
|
||
};
|
||
|
||
export function buildVaultHostFromDraft(
|
||
draft: VaultHostDraft,
|
||
): { ok: true; host: Host } | { ok: false; error: string } {
|
||
if (!draft || typeof draft !== 'object' || Array.isArray(draft)) {
|
||
return { ok: false, error: 'host must be an object.' };
|
||
}
|
||
const rawHostname = draft.hostname ?? draft.host ?? draft.ip;
|
||
const hostname = typeof rawHostname === 'string' ? rawHostname.trim() : '';
|
||
if (!hostname) {
|
||
return { ok: false, error: 'hostname is required.' };
|
||
}
|
||
if (!isSafeSshConfigValue(hostname)) {
|
||
return { ok: false, error: 'hostname must not contain line breaks or null bytes.' };
|
||
}
|
||
if (HOSTNAME_WHITESPACE.test(hostname)) {
|
||
return { ok: false, error: 'hostname must not contain whitespace.' };
|
||
}
|
||
|
||
const parsedProtocol = normalizeProtocol(draft.protocol);
|
||
const hasProtocolInput = draft.protocol !== undefined
|
||
&& draft.protocol !== null
|
||
&& !(typeof draft.protocol === 'string' && !draft.protocol.trim());
|
||
if (hasProtocolInput && parsedProtocol === undefined) {
|
||
return { ok: false, error: 'protocol must be ssh, telnet, or local.' };
|
||
}
|
||
const protocol = parsedProtocol ?? 'ssh';
|
||
const parsedOs = normalizeOs(draft.os);
|
||
const hasOsInput = draft.os !== undefined
|
||
&& draft.os !== null
|
||
&& !(typeof draft.os === 'string' && !draft.os.trim());
|
||
if (hasOsInput && parsedOs === undefined) {
|
||
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
|
||
}
|
||
const os = parsedOs === 'windows' || parsedOs === 'macos' ? parsedOs : 'linux';
|
||
const parsedPort = parsePort(draft.port);
|
||
const hasPortInput = draft.port !== undefined
|
||
&& draft.port !== null
|
||
&& !(typeof draft.port === 'string' && !draft.port.trim());
|
||
if (hasPortInput && parsedPort === undefined) {
|
||
return { ok: false, error: 'port must be an integer between 1 and 65535.' };
|
||
}
|
||
const port = parsedPort ?? defaultPortForProtocol(protocol);
|
||
const rawLabel = draft.label ?? draft.name;
|
||
if (rawLabel !== undefined && rawLabel !== null && typeof rawLabel !== 'string') {
|
||
return { ok: false, error: 'label must be a string.' };
|
||
}
|
||
const label = typeof rawLabel === 'string' && rawLabel.trim()
|
||
? rawLabel.trim()
|
||
: hostname;
|
||
if (draft.username !== undefined && draft.username !== null && typeof draft.username !== 'string') {
|
||
return { ok: false, error: 'username must be a string.' };
|
||
}
|
||
const username = typeof draft.username === 'string' ? draft.username.trim() : '';
|
||
if (!isSafeSshConfigValue(label)) {
|
||
return { ok: false, error: 'label must not contain line breaks or null bytes.' };
|
||
}
|
||
if (!isSafeSshConfigValue(username)) {
|
||
return { ok: false, error: 'username must not contain line breaks or null bytes.' };
|
||
}
|
||
const savePasswordInput = firstProvided(draft as Record<string, unknown>, ['savePassword']);
|
||
const savePassword = savePasswordInput.provided
|
||
? parseBoolean(savePasswordInput.value)
|
||
: undefined;
|
||
if (savePasswordInput.provided && savePassword === undefined) {
|
||
return { ok: false, error: 'savePassword must be true or false.' };
|
||
}
|
||
if (draft.password !== undefined && draft.password !== null && typeof draft.password !== 'string') {
|
||
return { ok: false, error: 'password must be a string.' };
|
||
}
|
||
const password = savePassword !== false && typeof draft.password === 'string' && draft.password
|
||
? draft.password
|
||
: undefined;
|
||
const rawKeyPath = draft.keyPath ?? draft.keypath;
|
||
if (rawKeyPath !== undefined && rawKeyPath !== null && typeof rawKeyPath !== 'string') {
|
||
return { ok: false, error: 'keyPath must be a string.' };
|
||
}
|
||
const keyPath = parseKeyPath(draft);
|
||
if (keyPath && !isSafeSshConfigValue(keyPath)) {
|
||
return { ok: false, error: 'keyPath must not contain line breaks or null bytes.' };
|
||
}
|
||
if (draft.passphrase !== undefined && draft.passphrase !== null && typeof draft.passphrase !== 'string') {
|
||
return { ok: false, error: 'passphrase must be a string.' };
|
||
}
|
||
if (typeof draft.passphrase === 'string' && draft.passphrase && !keyPath) {
|
||
return { ok: false, error: 'keyPath is required when passphrase is provided.' };
|
||
}
|
||
const tags = parseTags(draft.tags);
|
||
if (!tags.ok) return tags;
|
||
if (draft.group !== undefined && draft.group !== null && typeof draft.group !== 'string') {
|
||
return { ok: false, error: 'group must be a string.' };
|
||
}
|
||
if (draft.notes !== undefined && draft.notes !== null && typeof draft.notes !== 'string') {
|
||
return { ok: false, error: 'notes must be a string.' };
|
||
}
|
||
const notes = typeof draft.notes === 'string' && draft.notes.trim() ? draft.notes.trim() : undefined;
|
||
const now = Date.now();
|
||
|
||
return {
|
||
ok: true,
|
||
host: {
|
||
id: crypto.randomUUID(),
|
||
label,
|
||
hostname,
|
||
port,
|
||
username,
|
||
password,
|
||
...(savePassword !== undefined ? { savePassword } : {}),
|
||
group: normalizeGroupPath(draft.group),
|
||
tags: tags.tags,
|
||
os,
|
||
osOverride: parsedOs ?? 'auto',
|
||
protocol,
|
||
createdAt: now,
|
||
...(keyPath
|
||
? {
|
||
identityFilePaths: [keyPath],
|
||
authMethod: 'key' as const,
|
||
authPolicyVersion: 1 as const,
|
||
useSshAgent: false,
|
||
}
|
||
: {}),
|
||
...(notes ? { notes } : {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
export function applyVaultHostUpdate(
|
||
existingHosts: Host[],
|
||
existingGroups: string[],
|
||
hostId: string,
|
||
patch: VaultHostUpdatePatch,
|
||
options: VaultHostUpdateOptions = {},
|
||
): {
|
||
ok: true;
|
||
hosts: Host[];
|
||
customGroups: string[];
|
||
updatedHost: Host;
|
||
} | { ok: false; error: string } {
|
||
const hostIndex = existingHosts.findIndex((host) => host.id === hostId);
|
||
if (hostIndex < 0) return { ok: false, error: `Host "${hostId}" was not found.` };
|
||
|
||
const source = patch as Record<string, unknown>;
|
||
const label = firstProvided(source, ['label', 'name']);
|
||
const hostname = firstProvided(source, ['hostname', 'host', 'ip']);
|
||
const port = firstProvided(source, ['port']);
|
||
const username = firstProvided(source, ['username']);
|
||
const password = firstProvided(source, ['password']);
|
||
const savePassword = firstProvided(source, ['savePassword']);
|
||
const keyPath = firstProvided(source, ['keyPath', 'keypath']);
|
||
const group = firstProvided(source, ['group']);
|
||
const tags = firstProvided(source, ['tags']);
|
||
const notes = firstProvided(source, ['notes']);
|
||
const protocol = firstProvided(source, ['protocol']);
|
||
const os = firstProvided(source, ['os']);
|
||
const identityId = firstProvided(source, ['identityId']);
|
||
const jumpHostIds = firstProvided(source, ['jumpHostIds']);
|
||
const proxyProfileId = firstProvided(source, ['proxyProfileId']);
|
||
const startupCommand = firstProvided(source, ['startupCommand']);
|
||
const startupCommandRunMode = firstProvided(source, ['startupCommandRunMode']);
|
||
const environmentVariables = firstProvided(source, ['environmentVariables']);
|
||
const moshEnabled = firstProvided(source, ['moshEnabled']);
|
||
const moshServerPath = firstProvided(source, ['moshServerPath']);
|
||
const etEnabled = firstProvided(source, ['etEnabled']);
|
||
const etPort = firstProvided(source, ['etPort']);
|
||
const serialConfig = firstProvided(source, ['serialConfig']);
|
||
const provided = [label, hostname, port, username, password, savePassword, keyPath, group, tags, notes, protocol, os,
|
||
identityId, jumpHostIds, proxyProfileId, startupCommand, startupCommandRunMode, environmentVariables,
|
||
moshEnabled, moshServerPath, etEnabled, etPort, serialConfig]
|
||
.some((entry) => entry.provided);
|
||
if (!provided) return { ok: false, error: 'At least one host field is required.' };
|
||
|
||
const current = existingHosts[hostIndex];
|
||
let updated: Host = { ...current };
|
||
|
||
if (label.provided) {
|
||
if (typeof label.value !== 'string' || !label.value.trim()) {
|
||
return { ok: false, error: 'label must not be empty.' };
|
||
}
|
||
if (!isSafeSshConfigValue(label.value)) {
|
||
return { ok: false, error: 'label must not contain line breaks or null bytes.' };
|
||
}
|
||
updated.label = label.value.trim();
|
||
}
|
||
if (hostname.provided) {
|
||
if (typeof hostname.value !== 'string' || !hostname.value.trim()) {
|
||
return { ok: false, error: 'hostname must not be empty.' };
|
||
}
|
||
if (!isSafeSshConfigValue(hostname.value)) {
|
||
return { ok: false, error: 'hostname must not contain line breaks or null bytes.' };
|
||
}
|
||
const requestedProtocol = protocol.provided
|
||
? String(protocol.value ?? '').trim().toLowerCase()
|
||
: current.protocol;
|
||
if (requestedProtocol !== 'serial' && HOSTNAME_WHITESPACE.test(hostname.value.trim())) {
|
||
return { ok: false, error: 'hostname must not contain whitespace.' };
|
||
}
|
||
updated.hostname = hostname.value.trim();
|
||
}
|
||
if (port.provided) {
|
||
const parsedPort = parsePort(port.value);
|
||
if (parsedPort === undefined) {
|
||
return { ok: false, error: 'port must be an integer between 1 and 65535.' };
|
||
}
|
||
updated.port = parsedPort;
|
||
}
|
||
if (group.provided) {
|
||
if (typeof group.value !== 'string') {
|
||
return { ok: false, error: 'group must be a string.' };
|
||
}
|
||
updated.group = normalizeGroupPath(group.value);
|
||
}
|
||
if (protocol.provided) {
|
||
const rawProtocol = typeof protocol.value === 'string' ? protocol.value.trim().toLowerCase() : '';
|
||
const nextProtocol = rawProtocol === 'serial' ? 'serial' : normalizeProtocol(protocol.value);
|
||
if (!nextProtocol) {
|
||
return { ok: false, error: 'protocol must be ssh, telnet, local, or serial.' };
|
||
}
|
||
if (current.protocol === 'serial' && nextProtocol !== 'serial' && !hostname.provided) {
|
||
return { ok: false, error: 'hostname is required when changing a serial host to a network protocol.' };
|
||
}
|
||
if (
|
||
nextProtocol !== 'serial'
|
||
&& !port.provided
|
||
&& (
|
||
current.protocol === 'serial'
|
||
|| current.port === undefined
|
||
|| current.port === defaultPortForProtocol(current.protocol)
|
||
)
|
||
) {
|
||
updated.port = defaultPortForProtocol(nextProtocol);
|
||
}
|
||
updated.protocol = nextProtocol;
|
||
if (nextProtocol !== 'ssh') {
|
||
updated.moshEnabled = false;
|
||
updated.etEnabled = false;
|
||
}
|
||
}
|
||
|
||
if (os.provided) {
|
||
const nextOs = normalizeOs(os.value);
|
||
if (!nextOs) {
|
||
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
|
||
}
|
||
updated.osOverride = nextOs;
|
||
updated.os = nextOs === 'windows' || nextOs === 'macos' ? nextOs : 'linux';
|
||
}
|
||
|
||
if (identityId.provided) {
|
||
if (typeof identityId.value !== 'string') return { ok: false, error: 'identityId must be a string.' };
|
||
const nextIdentityId = identityId.value.trim();
|
||
const identity = options.identities?.find((item) => item.id === nextIdentityId);
|
||
if (nextIdentityId && !identity) return { ok: false, error: `Identity "${nextIdentityId}" was not found.` };
|
||
updated.identityId = nextIdentityId;
|
||
if (identity) {
|
||
updated.username = identity.username;
|
||
updated.authMethod = identity.authMethod;
|
||
updated.authPolicyVersion = 1;
|
||
updated.password = undefined;
|
||
updated.identityFileId = undefined;
|
||
updated.identityFilePaths = undefined;
|
||
updated.useSshAgent = false;
|
||
} else if (current.identityId) {
|
||
updated.authMethod = 'auto';
|
||
updated.authPolicyVersion = 1;
|
||
updated.identityFileId = undefined;
|
||
updated.identityFilePaths = undefined;
|
||
updated.useSshAgent = undefined;
|
||
}
|
||
}
|
||
|
||
if (jumpHostIds.provided) {
|
||
let ids: unknown = jumpHostIds.value;
|
||
if (typeof ids === 'string') {
|
||
try { ids = JSON.parse(ids); } catch { return { ok: false, error: 'jumpHostIds must be a valid JSON array.' }; }
|
||
}
|
||
if (!Array.isArray(ids)) return { ok: false, error: 'jumpHostIds must be an array.' };
|
||
const normalizedIds = ids.map(String).map((id) => id.trim()).filter(Boolean);
|
||
if (new Set(normalizedIds).size !== normalizedIds.length) return { ok: false, error: 'jumpHostIds must not contain duplicates.' };
|
||
if (normalizedIds.includes(hostId)) return { ok: false, error: 'A host cannot use itself as a jump host.' };
|
||
const missing = normalizedIds.find((id) => !existingHosts.some((candidate) => candidate.id === id));
|
||
if (missing) return { ok: false, error: `Jump host "${missing}" was not found.` };
|
||
const unsupported = normalizedIds.find((id) => {
|
||
const candidate = existingHosts.find((host) => host.id === id);
|
||
if (!candidate) return false;
|
||
const effectiveCandidate = options.resolveEffectiveHost?.(candidate) ?? candidate;
|
||
return !supportsSshJump(effectiveCandidate);
|
||
});
|
||
if (unsupported) return { ok: false, error: `Jump host "${unsupported}" does not support SSH jump connections.` };
|
||
updated.hostChain = { hostIds: normalizedIds };
|
||
}
|
||
if (proxyProfileId.provided) {
|
||
if (typeof proxyProfileId.value !== 'string') return { ok: false, error: 'proxyProfileId must be a string.' };
|
||
const nextProxyProfileId = proxyProfileId.value.trim();
|
||
if (nextProxyProfileId && !options.proxyProfiles?.some((profile) => profile.id === nextProxyProfileId)) {
|
||
return { ok: false, error: `Proxy profile "${nextProxyProfileId}" was not found.` };
|
||
}
|
||
updated.proxyProfileId = nextProxyProfileId;
|
||
updated.proxyConfig = undefined;
|
||
}
|
||
if (startupCommand.provided) {
|
||
if (typeof startupCommand.value !== 'string') return { ok: false, error: 'startupCommand must be a string.' };
|
||
updated.startupCommand = startupCommand.value;
|
||
}
|
||
if (startupCommandRunMode.provided) {
|
||
const mode = String(startupCommandRunMode.value ?? '');
|
||
if (mode !== 'paste' && mode !== 'lineDelay' && mode !== '') return { ok: false, error: 'startupCommandRunMode must be paste or lineDelay.' };
|
||
updated.startupCommandRunMode = mode === 'lineDelay' ? 'lineDelay' : undefined;
|
||
}
|
||
if (environmentVariables.provided) {
|
||
let raw: unknown = environmentVariables.value;
|
||
if (typeof raw === 'string') {
|
||
try { raw = JSON.parse(raw); } catch { return { ok: false, error: 'environmentVariables must be valid JSON.' }; }
|
||
}
|
||
if (Array.isArray(raw)) {
|
||
const entries = raw.map((item) => item as Record<string, unknown>);
|
||
if (entries.some((item) => typeof item?.name !== 'string')) return { ok: false, error: 'environmentVariables array entries require name and value.' };
|
||
updated.environmentVariables = entries.map((item) => ({ name: String(item.name), value: String(item.value ?? '') }));
|
||
} else if (raw && typeof raw === 'object') {
|
||
updated.environmentVariables = Object.entries(raw as Record<string, unknown>).map(([name, value]) => ({ name, value: String(value ?? '') }));
|
||
} else return { ok: false, error: 'environmentVariables must be a JSON object or array.' };
|
||
}
|
||
const nextMoshEnabled = moshEnabled.provided ? parseBoolean(moshEnabled.value) : undefined;
|
||
const nextEtEnabled = etEnabled.provided ? parseBoolean(etEnabled.value) : undefined;
|
||
if (moshEnabled.provided && nextMoshEnabled === undefined) {
|
||
return { ok: false, error: 'moshEnabled must be true or false.' };
|
||
}
|
||
if (etEnabled.provided && nextEtEnabled === undefined) {
|
||
return { ok: false, error: 'etEnabled must be true or false.' };
|
||
}
|
||
if (nextMoshEnabled === true && nextEtEnabled === true) {
|
||
return { ok: false, error: 'Mosh and ET cannot both be enabled.' };
|
||
}
|
||
if (moshEnabled.provided) {
|
||
updated.moshEnabled = nextMoshEnabled;
|
||
if (nextMoshEnabled) updated.etEnabled = false;
|
||
}
|
||
if (etEnabled.provided) {
|
||
updated.etEnabled = nextEtEnabled;
|
||
if (nextEtEnabled) updated.moshEnabled = false;
|
||
}
|
||
if (updated.protocol !== undefined && updated.protocol !== 'ssh' && (updated.moshEnabled || updated.etEnabled)) {
|
||
return { ok: false, error: 'Mosh and ET require the SSH protocol.' };
|
||
}
|
||
if (moshServerPath.provided) updated.moshServerPath = String(moshServerPath.value ?? '') || undefined;
|
||
if (etPort.provided) {
|
||
const value = parsePort(etPort.value);
|
||
if (value === undefined) return { ok: false, error: 'etPort must be an integer between 1 and 65535.' };
|
||
updated.etPort = value;
|
||
}
|
||
if (serialConfig.provided) {
|
||
let raw: unknown = serialConfig.value;
|
||
if (typeof raw === 'string') {
|
||
try { raw = JSON.parse(raw); } catch { return { ok: false, error: 'serialConfig must be valid JSON.' }; }
|
||
}
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'serialConfig must be a JSON object.' };
|
||
const config = raw as Record<string, unknown>;
|
||
const path = String(config.path ?? '').trim();
|
||
const baudRate = Number(config.baudRate);
|
||
if (!path || !Number.isInteger(baudRate) || baudRate <= 0) return { ok: false, error: 'serialConfig requires path and a positive baudRate.' };
|
||
if (!isSafeSshConfigValue(path)) {
|
||
return { ok: false, error: 'serialConfig.path must not contain line breaks or null bytes.' };
|
||
}
|
||
const dataBits = config.dataBits === undefined ? undefined : Number(config.dataBits);
|
||
if (dataBits !== undefined && ![5, 6, 7, 8].includes(dataBits)) return { ok: false, error: 'serialConfig.dataBits must be 5, 6, 7, or 8.' };
|
||
const stopBits = config.stopBits === undefined ? undefined : Number(config.stopBits);
|
||
if (stopBits !== undefined && ![1, 1.5, 2].includes(stopBits)) return { ok: false, error: 'serialConfig.stopBits must be 1, 1.5, or 2.' };
|
||
const parity = config.parity === undefined ? undefined : String(config.parity);
|
||
if (parity !== undefined && !['none', 'even', 'odd', 'mark', 'space'].includes(parity)) return { ok: false, error: 'serialConfig.parity is invalid.' };
|
||
const flowControl = config.flowControl === undefined ? undefined : String(config.flowControl);
|
||
if (flowControl !== undefined && !['none', 'xon/xoff', 'rts/cts'].includes(flowControl)) return { ok: false, error: 'serialConfig.flowControl is invalid.' };
|
||
const localEcho = config.localEcho === undefined ? undefined : parseBoolean(config.localEcho);
|
||
if (config.localEcho !== undefined && localEcho === undefined) return { ok: false, error: 'serialConfig.localEcho must be true or false.' };
|
||
const lineMode = config.lineMode === undefined ? undefined : parseBoolean(config.lineMode);
|
||
if (config.lineMode !== undefined && lineMode === undefined) return { ok: false, error: 'serialConfig.lineMode must be true or false.' };
|
||
const rawBackspaceBehavior = config.backspaceBehavior;
|
||
const backspaceBehavior = rawBackspaceBehavior === undefined
|
||
? updated.serialConfig?.backspaceBehavior
|
||
: String(rawBackspaceBehavior);
|
||
if (backspaceBehavior !== undefined && !['default', 'ctrl-h'].includes(backspaceBehavior)) {
|
||
return { ok: false, error: 'serialConfig.backspaceBehavior must be default or ctrl-h.' };
|
||
}
|
||
updated.serialConfig = {
|
||
path,
|
||
baudRate,
|
||
...(dataBits !== undefined ? { dataBits: dataBits as 5 | 6 | 7 | 8 } : {}),
|
||
...(stopBits !== undefined ? { stopBits: stopBits as 1 | 1.5 | 2 } : {}),
|
||
...(parity !== undefined ? { parity: parity as 'none' | 'even' | 'odd' | 'mark' | 'space' } : {}),
|
||
...(flowControl !== undefined ? { flowControl: flowControl as 'none' | 'xon/xoff' | 'rts/cts' } : {}),
|
||
...(localEcho !== undefined ? { localEcho } : {}),
|
||
...(lineMode !== undefined ? { lineMode } : {}),
|
||
...(backspaceBehavior !== undefined ? { backspaceBehavior: backspaceBehavior as 'default' | 'ctrl-h' } : {}),
|
||
};
|
||
}
|
||
if (updated.protocol === 'serial' && updated.serialConfig) {
|
||
if (serialConfig.provided && hostname.provided && updated.hostname !== updated.serialConfig.path) {
|
||
return { ok: false, error: 'hostname and serialConfig.path must match for serial hosts.' };
|
||
}
|
||
if (hostname.provided && !serialConfig.provided) {
|
||
updated.serialConfig = { ...updated.serialConfig, path: updated.hostname };
|
||
} else {
|
||
updated.hostname = updated.serialConfig.path;
|
||
}
|
||
updated.port = updated.serialConfig.baudRate;
|
||
}
|
||
if (
|
||
protocol.provided
|
||
&& current.protocol !== 'serial'
|
||
&& updated.protocol === 'serial'
|
||
&& !updated.serialConfig
|
||
) {
|
||
return { ok: false, error: 'serialConfig is required when protocol is serial.' };
|
||
}
|
||
|
||
const effectiveBeforeSavePassword = options.resolveEffectiveHost?.(updated) ?? updated;
|
||
if (effectiveBeforeSavePassword.moshEnabled && effectiveBeforeSavePassword.etEnabled) {
|
||
return { ok: false, error: 'Mosh and ET cannot both be enabled.' };
|
||
}
|
||
if (
|
||
effectiveBeforeSavePassword.protocol !== undefined
|
||
&& effectiveBeforeSavePassword.protocol !== 'ssh'
|
||
&& (effectiveBeforeSavePassword.moshEnabled || effectiveBeforeSavePassword.etEnabled)
|
||
) {
|
||
return { ok: false, error: 'Mosh and ET require the SSH protocol.' };
|
||
}
|
||
|
||
if (savePassword.provided) {
|
||
const nextSavePassword = parseBoolean(savePassword.value);
|
||
if (nextSavePassword === undefined) {
|
||
return { ok: false, error: 'savePassword must be true or false.' };
|
||
}
|
||
updated.savePassword = nextSavePassword;
|
||
if (!nextSavePassword) updated.password = undefined;
|
||
}
|
||
|
||
const effectiveCurrent = options.resolveEffectiveHost?.(updated) ?? updated;
|
||
const selectedIdentityId = effectiveCurrent.identityId ?? effectiveBeforeSavePassword.identityId;
|
||
const selectedIdentity = selectedIdentityId
|
||
? options.identities?.find((identity) => identity.id === selectedIdentityId)
|
||
: undefined;
|
||
|
||
if (username.provided) {
|
||
if (typeof username.value !== 'string') {
|
||
return { ok: false, error: 'username must be a string.' };
|
||
}
|
||
if (!isSafeSshConfigValue(username.value)) {
|
||
return { ok: false, error: 'username must not contain line breaks or null bytes.' };
|
||
}
|
||
updated.username = username.value.trim();
|
||
if (selectedIdentityId) {
|
||
updated.identityId = '';
|
||
if (selectedIdentity?.authMethod === 'password') {
|
||
if (effectiveCurrent.savePassword !== false) {
|
||
updated.password = selectedIdentity.password;
|
||
}
|
||
updated.authMethod = 'password';
|
||
updated.authPolicyVersion = 1;
|
||
} else if (selectedIdentity?.keyId) {
|
||
updated.identityFileId = selectedIdentity.keyId;
|
||
updated.authMethod = selectedIdentity.authMethod;
|
||
updated.authPolicyVersion = 1;
|
||
updated.useSshAgent = false;
|
||
}
|
||
}
|
||
}
|
||
if (savePassword.provided && updated.savePassword === false && !username.provided && selectedIdentity) {
|
||
if (selectedIdentity.authMethod === 'password') {
|
||
updated.identityId = '';
|
||
updated.username = selectedIdentity.username;
|
||
updated.authMethod = 'password';
|
||
updated.authPolicyVersion = 1;
|
||
} else {
|
||
updated.identityId = selectedIdentity.id;
|
||
updated.authMethod = selectedIdentity.authMethod;
|
||
updated.authPolicyVersion = 1;
|
||
}
|
||
}
|
||
if (password.provided) {
|
||
if (typeof password.value !== 'string') {
|
||
return { ok: false, error: 'password must be a string.' };
|
||
}
|
||
if (password.value && effectiveCurrent.savePassword === false) {
|
||
return {
|
||
ok: false,
|
||
error: 'This host is configured not to save passwords. Enable password saving before updating it.',
|
||
};
|
||
}
|
||
updated.password = password.value || undefined;
|
||
if (!password.value) {
|
||
updated.savePassword = false;
|
||
}
|
||
const keyPathIsEmpty = keyPath.provided
|
||
&& typeof keyPath.value === 'string'
|
||
&& !keyPath.value.trim();
|
||
if (password.value && keyPathIsEmpty) {
|
||
if (selectedIdentity && !username.provided) {
|
||
updated.username = selectedIdentity.username;
|
||
}
|
||
updated.authMethod = 'password';
|
||
updated.authPolicyVersion = 1;
|
||
updated.identityId = '';
|
||
updated.identityFileId = undefined;
|
||
updated.identityFilePaths = undefined;
|
||
updated.useSshAgent = false;
|
||
} else if (selectedIdentityId) {
|
||
if (selectedIdentity?.authMethod === 'password') {
|
||
updated.identityId = '';
|
||
updated.username = username.provided
|
||
? updated.username
|
||
: selectedIdentity.username;
|
||
updated.authMethod = 'password';
|
||
updated.authPolicyVersion = 1;
|
||
} else if (selectedIdentity && !username.provided) {
|
||
updated.identityId = selectedIdentity.id;
|
||
updated.authMethod = selectedIdentity.authMethod;
|
||
updated.authPolicyVersion = 1;
|
||
}
|
||
}
|
||
}
|
||
if (keyPath.provided) {
|
||
if (typeof keyPath.value !== 'string') {
|
||
return { ok: false, error: 'keyPath must be a string.' };
|
||
}
|
||
const nextKeyPath = keyPath.value.trim();
|
||
if (!isSafeSshConfigValue(nextKeyPath)) {
|
||
return { ok: false, error: 'keyPath must not contain line breaks or null bytes.' };
|
||
}
|
||
updated.identityFilePaths = nextKeyPath ? [nextKeyPath] : [];
|
||
if (nextKeyPath) {
|
||
updated.identityFileId = undefined;
|
||
updated.identityId = '';
|
||
updated.authMethod = 'key';
|
||
updated.authPolicyVersion = 1;
|
||
updated.useSshAgent = false;
|
||
} else if (
|
||
!updated.identityId
|
||
&& !updated.identityFileId
|
||
&& !effectiveCurrent.identityId
|
||
&& !effectiveCurrent.identityFileId
|
||
&& updated.authMethod !== 'password'
|
||
&& effectiveCurrent.authMethod === 'key'
|
||
) {
|
||
updated.authMethod = 'auto';
|
||
updated.authPolicyVersion = 1;
|
||
updated.useSshAgent = undefined;
|
||
}
|
||
}
|
||
if (tags.provided) {
|
||
const nextTags = parseTags(tags.value);
|
||
if (!nextTags.ok) return nextTags;
|
||
updated.tags = nextTags.tags;
|
||
}
|
||
if (notes.provided) {
|
||
if (typeof notes.value !== 'string') {
|
||
return { ok: false, error: 'notes must be a string.' };
|
||
}
|
||
updated.notes = notes.value.trim() || undefined;
|
||
}
|
||
if (options.managedSources) {
|
||
const targetManagedSource = options.managedSources
|
||
.filter((sourceInfo) => (
|
||
updated.group === sourceInfo.groupName
|
||
|| updated.group?.startsWith(`${sourceInfo.groupName}/`)
|
||
))
|
||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||
const canBeManaged = !updated.protocol || updated.protocol === 'ssh';
|
||
if (targetManagedSource && canBeManaged) {
|
||
for (const jumpHostId of updated.hostChain?.hostIds ?? []) {
|
||
const jumpHost = existingHosts.find((candidate) => candidate.id === jumpHostId);
|
||
if (!jumpHost) continue;
|
||
if (!isSafeSshJumpHostname(jumpHost.hostname)) {
|
||
return { ok: false, error: 'hostname contains characters that are unsafe for an SSH jump host.' };
|
||
}
|
||
if (jumpHost.username && !isSafeSshJumpUsername(jumpHost.username)) {
|
||
return { ok: false, error: 'username contains characters that are unsafe for an SSH jump host.' };
|
||
}
|
||
}
|
||
if (label.provided || current.managedSourceId !== targetManagedSource.id) {
|
||
updated.label = updated.label.replace(/\s/g, '');
|
||
}
|
||
updated.managedSourceId = targetManagedSource.id;
|
||
} else if (options.managedSources.length > 0 || !canBeManaged) {
|
||
updated.managedSourceId = undefined;
|
||
}
|
||
|
||
const managedSourceIds = new Set(options.managedSources.map((sourceInfo) => sourceInfo.id));
|
||
const isManagedJumpHost = existingHosts.some((candidate) => (
|
||
candidate.id !== current.id
|
||
&& candidate.managedSourceId
|
||
&& managedSourceIds.has(candidate.managedSourceId)
|
||
&& (!candidate.protocol || candidate.protocol === 'ssh')
|
||
&& candidate.hostChain?.hostIds?.includes(current.id)
|
||
));
|
||
if (isManagedJumpHost) {
|
||
if (!isSafeSshJumpHostname(updated.hostname)) {
|
||
return { ok: false, error: 'hostname contains characters that are unsafe for an SSH jump host.' };
|
||
}
|
||
if (updated.username && !isSafeSshJumpUsername(updated.username)) {
|
||
return { ok: false, error: 'username contains characters that are unsafe for an SSH jump host.' };
|
||
}
|
||
}
|
||
}
|
||
|
||
updated = sanitizeHost(updated);
|
||
if (updated.protocol === 'serial' && updated.serialConfig) {
|
||
updated.hostname = updated.serialConfig.path;
|
||
}
|
||
const hosts = [...existingHosts];
|
||
hosts[hostIndex] = updated;
|
||
const jumpGraphIssue = findIntroducedVaultJumpGraphIssue(
|
||
existingHosts,
|
||
hosts,
|
||
options.resolveEffectiveHost,
|
||
);
|
||
if (jumpGraphIssue) {
|
||
if (jumpGraphIssue.kind === 'protocol' && jumpGraphIssue.jumpHostId === current.id) {
|
||
return { ok: false, error: 'A host used as a jump host must keep an SSH connection type.' };
|
||
}
|
||
return { ok: false, error: jumpGraphIssue.error };
|
||
}
|
||
const groupConfigReference = findVaultGroupConfigJumpReference(
|
||
options.groupConfigs ?? [],
|
||
current.id,
|
||
);
|
||
if (groupConfigReference) {
|
||
const effectiveBefore = options.resolveEffectiveHost?.(current) ?? current;
|
||
const effectiveAfter = options.resolveEffectiveHost?.(updated) ?? updated;
|
||
if (supportsSshJump(effectiveBefore) && !supportsSshJump(effectiveAfter)) {
|
||
return { ok: false, error: 'A host used as a group jump host must keep an SSH connection type.' };
|
||
}
|
||
}
|
||
const customGroups = updated.group
|
||
? Array.from(new Set([...existingGroups, updated.group]))
|
||
: [...existingGroups];
|
||
return { ok: true, hosts, customGroups, updatedHost: updated };
|
||
}
|
||
|
||
export function applyVaultHostDelete(
|
||
existingHosts: Host[],
|
||
hostId: string,
|
||
resolveEffectiveHost?: (host: Host) => Host,
|
||
groupConfigs: GroupConfig[] = [],
|
||
): { ok: true; hosts: Host[]; deletedHost: Host } | { ok: false; error: string } {
|
||
const deletedHost = existingHosts.find((host) => host.id === hostId);
|
||
if (!deletedHost) return { ok: false, error: `Host "${hostId}" was not found.` };
|
||
if (findVaultGroupConfigJumpReference(groupConfigs, hostId)) {
|
||
return { ok: false, error: `Host "${hostId}" is still used as a group jump host.` };
|
||
}
|
||
const hosts = existingHosts.filter((host) => host.id !== hostId);
|
||
const jumpGraphIssue = findIntroducedVaultJumpGraphIssue(
|
||
existingHosts,
|
||
hosts,
|
||
resolveEffectiveHost,
|
||
);
|
||
if (jumpGraphIssue?.kind === 'missing' && jumpGraphIssue.jumpHostId === hostId) {
|
||
return { ok: false, error: `Host "${hostId}" is still used as a jump host.` };
|
||
}
|
||
if (jumpGraphIssue) return { ok: false, error: jumpGraphIssue.error };
|
||
return {
|
||
ok: true,
|
||
hosts,
|
||
deletedHost,
|
||
};
|
||
}
|
||
|
||
export function parseVaultHostDraftsInput(
|
||
value: unknown,
|
||
): { ok: true; drafts: VaultHostDraft[] } | { ok: false; error: string } {
|
||
let parsed: unknown = value;
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return { ok: false, error: 'hosts is required.' };
|
||
try {
|
||
parsed = JSON.parse(trimmed) as unknown;
|
||
} catch {
|
||
return { ok: false, error: 'hosts must be a JSON array string.' };
|
||
}
|
||
}
|
||
|
||
if (!Array.isArray(parsed)) {
|
||
return { ok: false, error: 'hosts must be a JSON array of host objects.' };
|
||
}
|
||
if (parsed.length === 0) {
|
||
return { ok: false, error: 'hosts array is empty.' };
|
||
}
|
||
|
||
return { ok: true, drafts: parsed as VaultHostDraft[] };
|
||
}
|
||
|
||
export function buildVaultHostsFromDrafts(
|
||
drafts: VaultHostDraft[],
|
||
): { hosts: Host[]; issues: VaultHostCreateIssue[]; keyPassphrases: VaultHostKeyPassphrase[] } {
|
||
const hosts: Host[] = [];
|
||
const issues: VaultHostCreateIssue[] = [];
|
||
const keyPassphrases: VaultHostKeyPassphrase[] = [];
|
||
|
||
drafts.forEach((draft, index) => {
|
||
const built = buildVaultHostFromDraft(draft);
|
||
if (!built.ok) {
|
||
issues.push({ index, error: built.error });
|
||
return;
|
||
}
|
||
hosts.push(built.host);
|
||
const keyPath = parseKeyPath(draft);
|
||
if (keyPath && typeof draft.passphrase === 'string' && draft.passphrase) {
|
||
keyPassphrases.push({ hostId: built.host.id, keyPath, passphrase: draft.passphrase });
|
||
}
|
||
});
|
||
|
||
return { hosts, issues, keyPassphrases };
|
||
}
|
||
|
||
export function applyVaultHostCreates(
|
||
existingHosts: Host[],
|
||
existingGroups: string[],
|
||
createdHosts: Host[],
|
||
options?: { skipDuplicates?: boolean },
|
||
): {
|
||
hosts: Host[];
|
||
customGroups: string[];
|
||
addedCount: number;
|
||
skippedExistingCount: number;
|
||
addedHosts: Host[];
|
||
} {
|
||
const skipDuplicates = options?.skipDuplicates !== false;
|
||
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
|
||
let newHosts = createdHosts;
|
||
let skippedExistingCount = 0;
|
||
|
||
if (skipDuplicates) {
|
||
newHosts = createdHosts.filter((host) => {
|
||
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
|
||
if (duplicate) skippedExistingCount++;
|
||
return !duplicate;
|
||
});
|
||
}
|
||
|
||
const customGroups = Array.from(
|
||
new Set([
|
||
...existingGroups,
|
||
...newHosts.map((host) => host.group).filter(Boolean),
|
||
]),
|
||
) as string[];
|
||
|
||
return {
|
||
hosts: [...existingHosts, ...newHosts].map(sanitizeHost),
|
||
customGroups,
|
||
addedCount: newHosts.length,
|
||
skippedExistingCount,
|
||
addedHosts: newHosts,
|
||
};
|
||
}
|