Files
NetMesh/domain/groupConfig.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

239 lines
8.7 KiB
TypeScript

import type { GroupConfig, Host } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
/**
* Migrate deprecated primary-font ids out of a GroupConfig's
* font-override fields. Symmetrical to sanitizeHost; both run on load
* to keep the same proportional-font protection working for group
* defaults too.
*/
export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
const migrated = migrateDeprecatedFontOverride(config);
const hasLegacyPasswordOnlyCredentials = migrated.authMethod === undefined
&& Boolean(migrated.password?.length)
&& !migrated.identityId
&& !migrated.identityFileId
&& !migrated.identityFilePaths?.length;
return hasLegacyPasswordOnlyCredentials
? { ...migrated, authMethod: 'password' }
: migrated;
}
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;
}
export const hasManualGroupSshCredentials = (config: Partial<GroupConfig>): boolean => [
config.username,
config.password,
config.savePassword,
config.authMethod,
config.identityFileId,
config.identityFilePaths,
].some((value) => value !== undefined);
export const hasManualGroupTelnetCredentials = (config: Partial<GroupConfig>): boolean => [
config.telnetUsername,
config.telnetPassword,
].some((value) => value !== undefined);
const hasUsableProxyProfileId = (
proxyProfileId: string | undefined,
options?: ApplyGroupDefaultsOptions,
): boolean => {
if (!proxyProfileId) return false;
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
};
/**
* Resolve merged group defaults by walking the ancestor chain.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
*/
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
options?: ApplyGroupDefaultsOptions,
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
const merged: Record<string, unknown> = {};
for (let i = 0; i < parts.length; i++) {
const ancestorPath = parts.slice(0, i + 1).join('/');
const config = configMap.get(ancestorPath);
if (config) {
const hasSshIdentitySetting = config.identityId !== undefined;
const hasManualSshCredentials = hasManualGroupSshCredentials(config);
if (hasSshIdentitySetting) {
delete merged.username;
delete merged.password;
delete merged.savePassword;
delete merged.authMethod;
delete merged.identityFileId;
delete merged.identityFilePaths;
} else if (!hasSshIdentitySetting && hasManualSshCredentials) {
const replacesInheritedIdentity = Boolean(merged.identityId);
delete merged.identityId;
if (replacesInheritedIdentity) {
delete merged.username;
delete merged.password;
delete merged.savePassword;
delete merged.authMethod;
delete merged.identityFileId;
delete merged.identityFilePaths;
}
}
const hasTelnetIdentitySetting = config.telnetIdentityId !== undefined;
const hasManualTelnetCredentials = hasManualGroupTelnetCredentials(config);
if (hasTelnetIdentitySetting) {
delete merged.telnetUsername;
delete merged.telnetPassword;
} else if (!hasTelnetIdentitySetting && hasManualTelnetCredentials) {
const replacesInheritedIdentity = Boolean(merged.telnetIdentityId);
delete merged.telnetIdentityId;
if (replacesInheritedIdentity) {
delete merged.telnetUsername;
delete merged.telnetPassword;
}
}
for (const [key, value] of Object.entries(config)) {
if (
key === 'proxyProfileId' &&
typeof value === 'string' &&
options?.validProxyProfileIds &&
!options.validProxyProfileIds.has(value)
) {
delete merged.proxyConfig;
}
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
(key === 'fontSize' && config.fontSizeOverride === false) ||
(key === 'fontWeight' && config.fontWeightOverride === false)
) {
continue;
}
if (key !== 'path' && value !== undefined) {
if (key === 'proxyProfileId') {
delete merged.proxyConfig;
}
if (key === 'proxyConfig') {
delete merged.proxyProfileId;
}
merged[key] = value;
}
}
if (config.themeOverride === false) {
delete merged.themeOverride;
}
if (config.fontFamilyOverride === false) {
delete merged.fontFamilyOverride;
}
if (config.fontSizeOverride === false) {
delete merged.fontSizeOverride;
}
if (config.fontWeightOverride === false) {
delete merged.fontWeightOverride;
}
}
}
return merged as Partial<GroupConfig>;
}
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'deviceType', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'etEnabled', 'etPort',
'telnetEnabled', 'telnetPort', 'telnetIdentityId', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
'backspaceBehavior',
];
const EMPTY_STRING_OVERRIDES_GROUP_DEFAULT = new Set<keyof GroupConfig>([
'telnetUsername',
'telnetPassword',
'telnetIdentityId',
// Empty-string host identityId = explicitly no identity (auth-retry save, #1956); do not re-inherit group identity.
'identityId',
]);
const SSH_CREDENTIAL_KEYS = new Set<keyof GroupConfig>([
'username',
'password',
'savePassword',
'authMethod',
'identityId',
'identityFileId',
'identityFilePaths',
]);
/**
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(
host: Host,
groupDefaults: Partial<GroupConfig>,
options?: ApplyGroupDefaultsOptions,
): Host {
const effective = { ...host };
const hostHasUsableProxyProfile = hasUsableProxyProfileId(host.proxyProfileId, options);
const hostUsername = host.username?.trim();
const hostHasManualSshCredentials = !host.identityId && Boolean(
(hostUsername && hostUsername !== 'root') ||
(host.authPolicyVersion === 1 && host.authMethod !== undefined) ||
host.password !== undefined ||
host.savePassword === false ||
host.identityFileId ||
host.identityFilePaths?.length,
);
const shouldSkipGroupSshCredentialBundle = Boolean(host.identityId) || (
Boolean(groupDefaults.identityId) &&
(host.identityId === '' || hostHasManualSshCredentials)
);
const primaryTelnetHasManualSharedCredentials = host.protocol === 'telnet' && Boolean(
(hostUsername && hostUsername !== 'root') ||
host.password !== undefined ||
host.savePassword === false
);
const hostHasManualTelnetCredentials = !host.telnetIdentityId && (
host.telnetUsername !== undefined ||
host.telnetPassword !== undefined ||
primaryTelnetHasManualSharedCredentials
);
for (const key of INHERITABLE_KEYS) {
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
if (key === 'password' && effective.savePassword === false) continue;
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
if (key === 'proxyProfileId') {
if (host.proxyConfig !== undefined || !groupDefaults.proxyProfileId) continue;
}
if (key === 'proxyConfig' && (host.proxyProfileId !== undefined || hostHasUsableProxyProfile)) continue;
const hostValue = (effective as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
const emptyStringIsOverride = EMPTY_STRING_OVERRIDES_GROUP_DEFAULT.has(key);
const shouldInherit =
hostValue === undefined ||
hostValue === null ||
(hostValue === '' && !emptyStringIsOverride);
if (shouldInherit && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;
}
}
return effective;
}
export function resolveGroupTerminalThemeId(
groupDefaults: Partial<GroupConfig> | undefined,
fallbackThemeId: string,
): string {
if (!groupDefaults) return fallbackThemeId;
return groupDefaults.theme || fallbackThemeId;
}