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
329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
import type { GroupConfig, Host, Identity, ManagedSource, ProxyProfile } from './models';
|
|
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
|
|
import {
|
|
findIntroducedVaultJumpGraphIssue,
|
|
findVaultGroupConfigJumpReference,
|
|
} from './vaultJumpGraph';
|
|
|
|
type GroupState = {
|
|
groups: string[];
|
|
configs: GroupConfig[];
|
|
hosts: Host[];
|
|
managedSources: ManagedSource[];
|
|
};
|
|
|
|
type Result = { ok: true; state: GroupState; config?: GroupConfig } | { ok: false; error: string };
|
|
|
|
const normalizePath = (value: unknown): string => String(value ?? '')
|
|
.replace(/\\/g, '/')
|
|
.split('/')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean)
|
|
.join('/');
|
|
|
|
function parseDefaults(value: unknown): Record<string, unknown> | { error: string } {
|
|
if (value === undefined || value === null || value === '') return {};
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
if (typeof value !== 'string') return { error: 'defaults must be a JSON object.' };
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? parsed as Record<string, unknown>
|
|
: { error: 'defaults must be a JSON object.' };
|
|
} catch {
|
|
return { error: 'defaults must be a valid JSON object.' };
|
|
}
|
|
}
|
|
|
|
const bool = (value: unknown): boolean | undefined => {
|
|
if (typeof value === 'boolean') return value;
|
|
const normalized = String(value ?? '').toLowerCase();
|
|
if (['true', '1', 'yes'].includes(normalized)) return true;
|
|
if (['false', '0', 'no'].includes(normalized)) return false;
|
|
return undefined;
|
|
};
|
|
|
|
const resolveEffectiveHostForConfigs = (configs: GroupConfig[]) => (host: Host): Host => host.group
|
|
? applyGroupDefaults(host, resolveGroupDefaults(host.group, configs))
|
|
: host;
|
|
|
|
const hasInvalidTransport = (value: Pick<Host, 'protocol' | 'moshEnabled' | 'etEnabled'>): boolean => (
|
|
Boolean(value.moshEnabled && value.etEnabled) || Boolean(
|
|
value.protocol !== undefined
|
|
&& value.protocol !== 'ssh'
|
|
&& (value.moshEnabled || value.etEnabled)
|
|
)
|
|
);
|
|
|
|
const collectTransportIssueKeys = (
|
|
state: Pick<GroupState, 'groups' | 'configs' | 'hosts'>,
|
|
canonicalGroupPath: (path: string) => string = (path) => path,
|
|
): Set<string> => {
|
|
const issues = new Set<string>();
|
|
const groupPaths = new Set([...state.groups, ...state.configs.map((config) => config.path)]);
|
|
for (const groupPath of groupPaths) {
|
|
if (hasInvalidTransport(resolveGroupDefaults(groupPath, state.configs))) {
|
|
issues.add(`group:${canonicalGroupPath(groupPath)}`);
|
|
}
|
|
}
|
|
const resolveHost = resolveEffectiveHostForConfigs(state.configs);
|
|
for (const host of state.hosts) {
|
|
if (hasInvalidTransport(resolveHost(host))) issues.add(`host:${host.id}`);
|
|
}
|
|
return issues;
|
|
};
|
|
|
|
const findIntroducedJumpGraphIssue = (before: GroupState, after: GroupState) =>
|
|
findIntroducedVaultJumpGraphIssue(
|
|
before.hosts,
|
|
after.hosts,
|
|
resolveEffectiveHostForConfigs(before.configs),
|
|
resolveEffectiveHostForConfigs(after.configs),
|
|
);
|
|
|
|
const findReferencedJumpHostProtocolRegression = (
|
|
before: Pick<GroupState, 'configs' | 'hosts'>,
|
|
after: Pick<GroupState, 'configs' | 'hosts'>,
|
|
): string | undefined => {
|
|
const referencedHostIds = new Set(
|
|
after.configs.flatMap((config) => config.hostChain?.hostIds ?? []),
|
|
);
|
|
const resolveBefore = resolveEffectiveHostForConfigs(before.configs);
|
|
const resolveAfter = resolveEffectiveHostForConfigs(after.configs);
|
|
|
|
return [...referencedHostIds].find((hostId) => {
|
|
const beforeHost = before.hosts.find((host) => host.id === hostId);
|
|
const afterHost = after.hosts.find((host) => host.id === hostId);
|
|
if (!beforeHost || !afterHost) return false;
|
|
const beforeProtocol = resolveBefore(beforeHost).protocol;
|
|
const afterProtocol = resolveAfter(afterHost).protocol;
|
|
return (beforeProtocol === undefined || beforeProtocol === 'ssh')
|
|
&& afterProtocol !== undefined
|
|
&& afterProtocol !== 'ssh';
|
|
});
|
|
};
|
|
|
|
export function patchGroupConfig(
|
|
current: GroupConfig,
|
|
rawDefaults: unknown,
|
|
identities: Identity[],
|
|
proxyProfiles: ProxyProfile[],
|
|
hosts: Host[],
|
|
resolveEffectiveHost?: (host: Host) => Host,
|
|
): { ok: true; config: GroupConfig } | { ok: false; error: string } {
|
|
const defaults = parseDefaults(rawDefaults);
|
|
if ('error' in defaults) return { ok: false, error: String(defaults.error) };
|
|
const next: GroupConfig = { ...current };
|
|
if (Object.hasOwn(defaults, 'username')) {
|
|
next.username = String(defaults.username ?? '');
|
|
if (!Object.hasOwn(defaults, 'identityId') && current.identityId) {
|
|
next.identityId = '';
|
|
next.authMethod = undefined;
|
|
next.password = undefined;
|
|
next.savePassword = undefined;
|
|
next.identityFileId = undefined;
|
|
next.identityFilePaths = undefined;
|
|
}
|
|
}
|
|
if (Object.hasOwn(defaults, 'startupCommand')) next.startupCommand = String(defaults.startupCommand ?? '');
|
|
if (Object.hasOwn(defaults, 'moshServerPath')) next.moshServerPath = String(defaults.moshServerPath ?? '');
|
|
if (Object.hasOwn(defaults, 'identityId')) {
|
|
const identityId = String(defaults.identityId ?? '');
|
|
const identity = identities.find((item) => item.id === identityId);
|
|
if (identityId && !identity) return { ok: false, error: `Identity "${identityId}" was not found.` };
|
|
next.identityId = identityId;
|
|
if (identity) {
|
|
next.username = identity.username;
|
|
next.authMethod = identity.authMethod;
|
|
next.password = undefined;
|
|
next.savePassword = undefined;
|
|
next.identityFileId = undefined;
|
|
next.identityFilePaths = undefined;
|
|
} else {
|
|
if (!Object.hasOwn(defaults, 'username')) next.username = undefined;
|
|
next.authMethod = undefined;
|
|
}
|
|
}
|
|
if (Object.hasOwn(defaults, 'proxyProfileId')) {
|
|
const proxyProfileId = String(defaults.proxyProfileId ?? '');
|
|
if (proxyProfileId && !proxyProfiles.some((profile) => profile.id === proxyProfileId)) {
|
|
return { ok: false, error: `Proxy profile "${proxyProfileId}" was not found.` };
|
|
}
|
|
next.proxyProfileId = proxyProfileId;
|
|
next.proxyConfig = undefined;
|
|
}
|
|
if (Object.hasOwn(defaults, 'jumpHostIds')) {
|
|
let ids: unknown = defaults.jumpHostIds;
|
|
if (typeof ids === 'string') {
|
|
try { ids = JSON.parse(ids); } catch { return { ok: false, error: 'jumpHostIds must be a JSON array.' }; }
|
|
}
|
|
if (!Array.isArray(ids)) return { ok: false, error: 'jumpHostIds must be an array.' };
|
|
const hostIds = ids.map(String).map((id) => id.trim()).filter(Boolean);
|
|
if (new Set(hostIds).size !== hostIds.length) return { ok: false, error: 'jumpHostIds must not contain duplicates.' };
|
|
const missing = hostIds.find((id) => !hosts.some((host) => host.id === id));
|
|
if (missing) return { ok: false, error: `Jump host "${missing}" was not found.` };
|
|
const unsupported = hostIds.find((id) => {
|
|
const host = hosts.find((candidate) => candidate.id === id);
|
|
if (!host) return false;
|
|
const effectiveHost = resolveEffectiveHost?.(host) ?? host;
|
|
return effectiveHost.protocol !== undefined && effectiveHost.protocol !== 'ssh';
|
|
});
|
|
if (unsupported) return { ok: false, error: `Jump host "${unsupported}" does not support SSH jump connections.` };
|
|
next.hostChain = { hostIds };
|
|
}
|
|
if (Object.hasOwn(defaults, 'environmentVariables')) {
|
|
const raw = defaults.environmentVariables;
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
return { ok: false, error: 'environmentVariables must be a JSON object.' };
|
|
}
|
|
next.environmentVariables = Object.entries(raw).map(([name, value]) => ({ name, value: String(value ?? '') }));
|
|
}
|
|
for (const key of ['moshEnabled', 'etEnabled'] as const) {
|
|
if (Object.hasOwn(defaults, key)) {
|
|
const parsed = bool(defaults[key]);
|
|
if (parsed === undefined) return { ok: false, error: `${key} must be true or false.` };
|
|
next[key] = parsed;
|
|
}
|
|
}
|
|
if (next.moshEnabled && next.etEnabled) {
|
|
return { ok: false, error: 'Mosh and ET cannot both be enabled.' };
|
|
}
|
|
if (Object.hasOwn(defaults, 'etPort')) {
|
|
const etPort = Number(defaults.etPort);
|
|
if (!Number.isInteger(etPort) || etPort < 1 || etPort > 65535) return { ok: false, error: 'etPort must be between 1 and 65535.' };
|
|
next.etPort = etPort;
|
|
}
|
|
return { ok: true, config: next };
|
|
}
|
|
|
|
export function upsertGroup(
|
|
state: GroupState,
|
|
pathValue: unknown,
|
|
defaults: unknown,
|
|
identities: Identity[],
|
|
proxyProfiles: ProxyProfile[],
|
|
options: { create?: boolean; newPath?: unknown } = {},
|
|
): Result {
|
|
const path = normalizePath(pathValue);
|
|
if (!path) return { ok: false, error: 'path is required.' };
|
|
if (options.create && state.groups.includes(path)) return { ok: false, error: `Group "${path}" already exists.` };
|
|
if (!options.create && !state.groups.includes(path)) return { ok: false, error: `Group "${path}" was not found.` };
|
|
const newPath = normalizePath(options.newPath ?? path);
|
|
if (!newPath) return { ok: false, error: 'newPath must not be empty.' };
|
|
if (newPath.startsWith(`${path}/`)) return { ok: false, error: 'A group cannot be moved inside itself.' };
|
|
if (newPath !== path) {
|
|
const belongsToRenamedTree = (candidate: string) => candidate === path || candidate.startsWith(`${path}/`);
|
|
const renameCandidate = (candidate: string) => candidate === path
|
|
? newPath
|
|
: `${newPath}${candidate.slice(path.length)}`;
|
|
const occupiedPaths = new Set([
|
|
...state.groups.filter((candidate) => !belongsToRenamedTree(candidate)),
|
|
...state.configs.map((config) => config.path).filter((candidate) => !belongsToRenamedTree(candidate)),
|
|
]);
|
|
const collision = [...state.groups, ...state.configs.map((config) => config.path)]
|
|
.filter(belongsToRenamedTree)
|
|
.map(renameCandidate)
|
|
.find((candidate) => occupiedPaths.has(candidate));
|
|
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
|
|
}
|
|
const rename = (candidate: string) => candidate === path
|
|
? newPath
|
|
: candidate.startsWith(`${path}/`) ? `${newPath}${candidate.slice(path.length)}` : candidate;
|
|
const current = state.configs.find((config) => config.path === path) ?? { path };
|
|
const prospectiveConfig = { ...current, path: newPath };
|
|
const prospectiveConfigs = [
|
|
...state.configs
|
|
.filter((config) => config.path !== path)
|
|
.map((config) => ({ ...config, path: rename(config.path) })),
|
|
prospectiveConfig,
|
|
];
|
|
const resolveProspectiveHost = (host: Host): Host => {
|
|
const prospectiveHost = host.group ? { ...host, group: rename(host.group) } : host;
|
|
if (!prospectiveHost.group) return prospectiveHost;
|
|
return applyGroupDefaults(
|
|
prospectiveHost,
|
|
resolveGroupDefaults(prospectiveHost.group, prospectiveConfigs),
|
|
);
|
|
};
|
|
const patched = patchGroupConfig(
|
|
prospectiveConfig,
|
|
defaults,
|
|
identities,
|
|
proxyProfiles,
|
|
state.hosts,
|
|
resolveProspectiveHost,
|
|
);
|
|
if ('error' in patched) return { ok: false, error: patched.error };
|
|
const groups = options.create
|
|
? Array.from(new Set([...state.groups, newPath]))
|
|
: Array.from(new Set(state.groups.map(rename)));
|
|
const configs = [
|
|
...state.configs.filter((config) => config.path !== path).map((config) => ({ ...config, path: rename(config.path) })),
|
|
patched.config,
|
|
];
|
|
const nextState: GroupState = {
|
|
groups,
|
|
configs,
|
|
hosts: state.hosts.map((host) => host.group ? { ...host, group: rename(host.group) } : host),
|
|
managedSources: state.managedSources.map((source) => ({ ...source, groupName: rename(source.groupName) })),
|
|
};
|
|
const beforeTransportIssues = collectTransportIssueKeys(state);
|
|
const renamedGroupPaths = new Map(
|
|
[...state.groups, ...state.configs.map((config) => config.path)]
|
|
.filter((candidate) => candidate === path || candidate.startsWith(`${path}/`))
|
|
.map((candidate) => [rename(candidate), candidate]),
|
|
);
|
|
const afterTransportIssues = collectTransportIssueKeys(
|
|
nextState,
|
|
(groupPath) => renamedGroupPaths.get(groupPath) ?? groupPath,
|
|
);
|
|
const introducedTransportIssue = [...afterTransportIssues]
|
|
.find((issue) => !beforeTransportIssues.has(issue));
|
|
if (introducedTransportIssue) {
|
|
return { ok: false, error: `${introducedTransportIssue} has incompatible inherited Mosh, ET, or protocol settings.` };
|
|
}
|
|
const regressedJumpHostId = findReferencedJumpHostProtocolRegression(state, nextState);
|
|
if (regressedJumpHostId) {
|
|
return { ok: false, error: `Host "${regressedJumpHostId}" is still used as a group jump host and must keep an SSH connection type.` };
|
|
}
|
|
const jumpGraphIssue = findIntroducedJumpGraphIssue(state, nextState);
|
|
if (jumpGraphIssue) return { ok: false, error: jumpGraphIssue.error };
|
|
return {
|
|
ok: true,
|
|
state: nextState,
|
|
config: patched.config,
|
|
};
|
|
}
|
|
|
|
export function deleteGroup(state: GroupState, pathValue: unknown, deleteHosts: boolean): Result {
|
|
const path = normalizePath(pathValue);
|
|
if (!path || !state.groups.includes(path)) return { ok: false, error: `Group "${path}" was not found.` };
|
|
if (state.managedSources.some((source) => source.groupName === path || source.groupName.startsWith(`${path}/`))) {
|
|
return { ok: false, error: 'Managed groups must be unmanaged before the AI can delete them.' };
|
|
}
|
|
const inside = (candidate?: string) => Boolean(candidate && (candidate === path || candidate.startsWith(`${path}/`)));
|
|
const hasManagedParent = state.managedSources.some((source) => path.startsWith(`${source.groupName}/`));
|
|
const nextState: GroupState = {
|
|
groups: state.groups.filter((group) => !inside(group)),
|
|
configs: state.configs.filter((config) => !inside(config.path)),
|
|
hosts: deleteHosts
|
|
? state.hosts.filter((host) => !inside(host.group))
|
|
: state.hosts.map((host) => inside(host.group)
|
|
? { ...host, group: undefined, ...(hasManagedParent ? {} : { managedSourceId: undefined }) }
|
|
: host),
|
|
managedSources: state.managedSources,
|
|
};
|
|
const deletedHostIds = state.hosts
|
|
.filter((host) => !nextState.hosts.some((candidate) => candidate.id === host.id))
|
|
.map((host) => host.id);
|
|
const referencedDeletedHostId = deletedHostIds.find((hostId) => (
|
|
findVaultGroupConfigJumpReference(nextState.configs, hostId)
|
|
));
|
|
if (referencedDeletedHostId) {
|
|
return { ok: false, error: `Host "${referencedDeletedHostId}" is still used as a group jump host.` };
|
|
}
|
|
const jumpGraphIssue = findIntroducedJumpGraphIssue(state, nextState);
|
|
if (jumpGraphIssue) return { ok: false, error: jumpGraphIssue.error };
|
|
return { ok: true, state: nextState };
|
|
}
|