Files
NetMesh/domain/hostConnectScripts.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

457 lines
16 KiB
TypeScript

import type { Host, Snippet } from './models';
import { isScriptSnippet } from './snippetScript.ts';
import {
getScriptsLinkedToHost,
linkHostToScript,
snippetTargetsHostExplicitly,
snippetTargetsHostGroup,
unlinkHostFromScripts,
} from './snippetTargets.ts';
import { sortByVaultOrder } from './vaultOrder.ts';
function isOnConnectScript(snippet: Snippet): boolean {
return isScriptSnippet(snippet) && snippet.trigger === 'onConnect' && Boolean(snippet.id);
}
function scriptById(snippets: Snippet[], scriptId: string): Snippet | undefined {
return snippets.find((snippet) => snippet.id === scriptId && isScriptSnippet(snippet));
}
function pruneConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
const snippet = scriptById(snippets, id);
if (!snippet || !isOnConnectScript(snippet)) continue;
seen.add(id);
result.push(id);
}
return result;
}
/**
* Draft/edit queue prune: keep any existing script IDs.
* Host save promotes non-onConnect scripts via prepareSnippetForHostConnectQueue.
*/
function pruneEditableConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
const snippet = scriptById(snippets, id);
if (!snippet) continue;
seen.add(id);
result.push(id);
}
return result;
}
/** Global onConnect scripts (targetsAllHosts), sorted by vault order. */
export function getGlobalConnectScripts(snippets: Snippet[]): Snippet[] {
return sortByVaultOrder(
snippets.filter(
(snippet) => isOnConnectScript(snippet) && Boolean(snippet.targetsAllHosts),
),
);
}
/** Derive initial connectScriptIds from legacy host + snippet bindings. */
export function migrateHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
const push = (scriptId?: string) => {
if (!scriptId || seen.has(scriptId)) return;
const snippet = scriptById(snippets, scriptId);
if (!snippet || !isOnConnectScript(snippet)) return;
if (!snippetTargetsHostExplicitly(snippet, host.id) && !snippet.targetsAllHosts) return;
seen.add(scriptId);
ordered.push(scriptId);
};
push(host.loginScriptId);
for (const snippet of getScriptsLinkedToHost(snippets, host.id)) {
if (snippet.trigger === 'onConnect') {
push(snippet.id);
}
}
for (const snippet of sortByVaultOrder(snippets)) {
if (!isOnConnectScript(snippet)) continue;
if (snippet.targetsAllHosts) continue;
if (!snippetTargetsHostExplicitly(snippet, host.id)) continue;
push(snippet.id);
}
return ordered;
}
/** Effective ordered script IDs for a host (lazy migrate + prune). */
export function getHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
if (host.connectScriptIds !== undefined) {
return pruneConnectScriptIds(host.connectScriptIds, snippets);
}
return migrateHostConnectScriptIds(host, snippets);
}
/**
* Host-details draft queue: includes scripts pending promote-to-onConnect on save.
* Runtime connect still uses getHostConnectScriptIds (onConnect only).
*/
export function getEditableHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
if (host.connectScriptIds !== undefined) {
return pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
}
return migrateHostConnectScriptIds(host, snippets);
}
export function ensureHostConnectScriptIds(host: Host, snippets: Snippet[]): Host {
if (host.connectScriptIds !== undefined) {
const pruned = pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
if (pruned.length === host.connectScriptIds.length
&& pruned.every((id, index) => id === host.connectScriptIds![index])) {
return host;
}
return { ...host, connectScriptIds: pruned };
}
const migrated = migrateHostConnectScriptIds(host, snippets);
return migrated.length > 0 ? { ...host, connectScriptIds: migrated } : host;
}
/** True when host references connect scripts that are not present in snippets yet. */
export function hasUnresolvedConnectScriptBindings(host: Host, snippets: Snippet[]): boolean {
const candidateIds = new Set<string>();
if (host.loginScriptId) candidateIds.add(host.loginScriptId);
for (const id of host.connectScriptIds ?? []) {
if (id) candidateIds.add(id);
}
for (const id of candidateIds) {
if (!snippets.some((snippet) => snippet.id === id)) {
return true;
}
}
return false;
}
/** Group-scoped onConnect scripts inherited dynamically from the host's current group. */
export function getGroupConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
return sortByVaultOrder(
snippets.filter(
(snippet) => isOnConnectScript(snippet)
&& !snippet.targetsAllHosts
&& snippetTargetsHostGroup(snippet, host),
),
);
}
/** Resolve full onConnect run list: global, group-inherited, then host queue. */
export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
const hostIds = getHostConnectScriptIds(host, snippets);
const hostIdSet = new Set(hostIds);
const globals = getGlobalConnectScripts(snippets).filter(
(snippet) => snippet.id && !hostIdSet.has(snippet.id),
);
const inheritedIds = new Set(globals.map((snippet) => snippet.id));
const groupScripts = getGroupConnectScriptsForHost(host, snippets).filter(
(snippet) => snippet.id && !hostIdSet.has(snippet.id) && !inheritedIds.has(snippet.id),
);
const hostScripts = hostIds
.map((id) => scriptById(snippets, id))
.filter((snippet): snippet is Snippet => Boolean(snippet));
return [...globals, ...groupScripts, ...hostScripts];
}
/**
* Whether connecting this host can run automation that depends on the initial
* login output. Missing referenced scripts still count: vault hydration or a
* later sync may restore them after the terminal has already started.
*/
export function hasHostConnectAutomation(host: Host, snippets: Snippet[]): boolean {
return hasUnresolvedConnectScriptBindings(host, snippets)
|| resolveConnectScriptsForHost(host, snippets).length > 0;
}
export function shouldUseFreshSshConnectionForAutomation(options: {
host: Host;
snippets: Snippet[];
vaultInitialized: boolean;
hasPendingScript?: boolean;
connectAutomationConsumed?: boolean;
}): boolean {
return options.hasPendingScript === true
|| (
options.connectAutomationConsumed !== true
&& (
!options.vaultInitialized
|| hasHostConnectAutomation(options.host, options.snippets)
)
);
}
/**
* Mark the current connection's automation decision as final once the vault
* has hydrated, even when it hydrated to an empty script list. This prevents a
* later sync from starting a newly arrived global script against a connection
* whose initial login output may already have been skipped.
*/
export function shouldMarkConnectAutomationConsumed(options: {
allConnectScriptsDone: boolean;
vaultInitialized: boolean;
hasUnresolvedBindings: boolean;
}): boolean {
return options.allConnectScriptsDone
&& options.vaultInitialized
&& !options.hasUnresolvedBindings;
}
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const snippet = scriptById(snippets, scriptId);
if (!snippet) return host;
const current = getEditableHostConnectScriptIds(host, snippets);
if (current.includes(scriptId)) {
return { ...host, connectScriptIds: current };
}
return { ...host, connectScriptIds: [...current, scriptId] };
}
export function removeHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const current = getEditableHostConnectScriptIds(host, snippets);
const next = current.filter((id) => id !== scriptId);
return { ...host, connectScriptIds: next };
}
export function reorderHostConnectScript(
host: Host,
draggedScriptId: string,
targetScriptId: string,
position: 'before' | 'after',
snippets: Snippet[],
): Host {
if (draggedScriptId === targetScriptId) return host;
const current = [...getEditableHostConnectScriptIds(host, snippets)];
const fromIndex = current.indexOf(draggedScriptId);
const targetIndex = current.indexOf(targetScriptId);
if (fromIndex === -1 || targetIndex === -1) return host;
current.splice(fromIndex, 1);
let insertIndex = current.indexOf(targetScriptId);
if (insertIndex === -1) return host;
if (position === 'after') insertIndex += 1;
current.splice(insertIndex, 0, draggedScriptId);
return { ...host, connectScriptIds: current };
}
export function prepareSnippetForHostConnectQueue(snippet: Snippet, hostId: string): Snippet {
if (!isScriptSnippet(snippet)) return snippet;
if (snippet.targetsAllHosts) {
return snippet.trigger === 'onConnect'
? snippet
: { ...snippet, trigger: 'onConnect' };
}
return {
...linkHostToScript(snippet, hostId),
trigger: 'onConnect',
};
}
function connectQueueSnippetNeedsPromote(snippet: Snippet, hostId: string): boolean {
if (!isScriptSnippet(snippet)) return false;
if (snippet.trigger !== 'onConnect') return true;
if (snippet.targetsAllHosts) return false;
return !snippetTargetsHostExplicitly(snippet, hostId);
}
function snippetTargetsEqual(left: Snippet, right: Snippet): boolean {
if (Boolean(left.targetsAllHosts) !== Boolean(right.targetsAllHosts)) return false;
const leftTargets = left.targets ?? [];
const rightTargets = right.targets ?? [];
if (leftTargets.length !== rightTargets.length) return false;
if (!leftTargets.every((id, index) => id === rightTargets[index])) return false;
const leftGroups = left.targetGroups ?? [];
const rightGroups = right.targetGroups ?? [];
if (leftGroups.length !== rightGroups.length) return false;
return leftGroups.every((path, index) => path === rightGroups[index]);
}
/**
* After promoting a script to onConnect, ensure every remaining target host with an
* explicit connectScriptIds queue includes it. Hosts without an explicit queue still
* pick the script up via migrateHostConnectScriptIds.
*/
export function ensureTargetHostsHaveConnectScript(
hosts: Host[],
snippet: Snippet,
snippets: Snippet[],
excludeHostId?: string,
): Host[] {
if (!snippet.id || !isScriptSnippet(snippet) || snippet.trigger !== 'onConnect') return hosts;
if (snippet.targetsAllHosts) return hosts;
const targetIds = new Set(snippet.targets ?? []);
if (targetIds.size === 0) return hosts;
let changed = false;
const nextHosts = hosts.map((host) => {
if (host.id === excludeHostId) return host;
if (!targetIds.has(host.id)) return host;
if (host.connectScriptIds === undefined) return host;
const updated = appendHostConnectScript(host, snippet.id!, snippets);
if (updated !== host) changed = true;
return updated;
});
return changed ? nextHosts : hosts;
}
export type SyncHostConnectQueueSaveOptions = {
/** Snippets snapshot from when the host editor opened (detect concurrent demotion). */
baselineSnippets?: Snippet[];
/** When provided, promote also syncs other hosts that remain in targets. */
hosts?: Host[];
};
/**
* Sync script metadata when saving a host connect queue.
* Promotes draft/manual queue entries, preserves global onConnect scripts,
* drops concurrently demoted entries, and optionally syncs peer host queues.
*/
export function syncSnippetsForHostConnectQueueSave(
snippets: Snippet[],
hostId: string,
previousQueueIds: string[],
nextQueueIds: string[],
options: SyncHostConnectQueueSaveOptions = {},
): {
snippets: Snippet[];
hosts: Host[];
connectScriptIds: string[];
changed: boolean;
} {
const previousSet = new Set(previousQueueIds);
const baseline = options.baselineSnippets ?? snippets;
let nextSnippets = snippets;
let nextHosts = options.hosts ?? [];
let changed = false;
const retainedIds: string[] = [];
const demotedDropIds = new Set<string>();
for (const scriptId of nextQueueIds) {
const item = nextSnippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
if (!item) continue;
if (!connectQueueSnippetNeedsPromote(item, hostId)) {
retainedIds.push(scriptId);
continue;
}
const newlyAdded = !previousSet.has(scriptId);
const baselineItem = baseline.find((entry) => entry.id === scriptId);
const baselineTrigger = baselineItem && isScriptSnippet(baselineItem)
? baselineItem.trigger
: undefined;
if (!newlyAdded && baselineTrigger === 'onConnect' && item.trigger !== 'onConnect') {
// Concurrent demotion while the editor stayed open: keep demotion, drop stale queue id.
demotedDropIds.add(scriptId);
continue;
}
if (
!newlyAdded
&& baselineItem
&& isScriptSnippet(baselineItem)
&& (Boolean(baselineItem.targetsAllHosts) || snippetTargetsHostExplicitly(baselineItem, hostId))
&& !(Boolean(item.targetsAllHosts) || snippetTargetsHostExplicitly(item, hostId))
) {
// Concurrent target removal for this host: do not re-link on save.
demotedDropIds.add(scriptId);
continue;
}
if (
!newlyAdded
&& baselineTrigger
&& baselineTrigger !== 'onConnect'
&& item.trigger !== baselineTrigger
) {
// Concurrent non-onConnect trigger edit (e.g. manual -> onOutput): do not overwrite.
demotedDropIds.add(scriptId);
continue;
}
const prepared = prepareSnippetForHostConnectQueue(item, hostId);
if (
prepared.trigger !== item.trigger
|| !snippetTargetsEqual(prepared, item)
) {
nextSnippets = nextSnippets.map((entry) => (entry.id === scriptId ? prepared : entry));
changed = true;
if (options.hosts) {
const syncedHosts = ensureTargetHostsHaveConnectScript(
nextHosts,
prepared,
nextSnippets,
hostId,
);
if (syncedHosts !== nextHosts) {
nextHosts = syncedHosts;
changed = true;
}
}
}
retainedIds.push(scriptId);
}
for (const scriptId of previousQueueIds) {
if (retainedIds.includes(scriptId)) continue;
if (demotedDropIds.has(scriptId)) continue;
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
if (unlinked !== nextSnippets) {
nextSnippets = unlinked;
changed = true;
}
}
if (
retainedIds.length !== nextQueueIds.length
|| retainedIds.some((id, index) => id !== nextQueueIds[index])
) {
changed = true;
}
return {
snippets: nextSnippets,
hosts: nextHosts,
connectScriptIds: retainedIds,
changed,
};
}
export function syncHostsForSnippetTargetChange(
hosts: Host[],
snippet: Snippet,
prevTargetIds: string[] | undefined,
snippets: Snippet[],
): Host[] {
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onConnect' || !snippet.id) {
return hosts;
}
if (snippet.targetsAllHosts) {
return hosts.map((host) => removeHostConnectScript(host, snippet.id!, snippets));
}
const prev = new Set(prevTargetIds ?? []);
const next = new Set(snippet.targets ?? []);
const added = [...next].filter((id) => !prev.has(id));
const removed = [...prev].filter((id) => !next.has(id));
if (added.length === 0 && removed.length === 0) return hosts;
return hosts.map((host) => {
let updated = host;
if (added.includes(host.id)) {
updated = appendHostConnectScript(updated, snippet.id!, snippets);
}
if (removed.includes(host.id)) {
updated = removeHostConnectScript(updated, snippet.id!, snippets);
}
return updated;
});
}