Adding an SSH connection now starts from the hosts in the SSH config instead of a blank command field, ports and install options sit behind Advanced settings, and each connection reports one of three states with the failure text and an action that resolves it. Remote installs no longer touch the root-owned global npm prefix: npm is pinned to a prefix under $HOME and bun is resolved at its known location, because an SSH login shell exposes neither on PATH. The opencode CLI is resolved the same way and handed to the remote server through OPENCODE_BINARY, and the server is started and stopped through the resolved binary rather than PATH — the HTTP shutdown route sits behind UI authentication and never stopped anything. A managed remote server can also be published to the remote machine's own network. That requires a UI password, enforced in the form and again in the SSH manager.
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
|
|
|
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
|
|
type DesktopBridgeGlobal = {
|
|
listen?: (
|
|
event: string,
|
|
handler: (evt: { payload?: unknown }) => void,
|
|
) => Promise<() => void>;
|
|
};
|
|
|
|
type DesktopSshRemoteMode = 'managed' | 'external';
|
|
type DesktopSshInstallMethod = 'auto' | 'npm' | 'bun';
|
|
type DesktopSshSecretStore = 'never' | 'settings';
|
|
|
|
type DesktopSshStoredSecret = {
|
|
enabled: boolean;
|
|
value?: string;
|
|
store: DesktopSshSecretStore;
|
|
};
|
|
|
|
export type DesktopSshPortForwardType = 'local' | 'remote' | 'dynamic';
|
|
|
|
export type DesktopSshPortForward = {
|
|
id: string;
|
|
enabled: boolean;
|
|
type: DesktopSshPortForwardType;
|
|
localHost?: string;
|
|
localPort?: number;
|
|
remoteHost?: string;
|
|
remotePort?: number;
|
|
};
|
|
|
|
export type DesktopSshInstance = {
|
|
id: string;
|
|
nickname?: string;
|
|
sshCommand: string;
|
|
sshParsed?: {
|
|
destination: string;
|
|
args: string[];
|
|
};
|
|
connectionTimeoutSec: number;
|
|
remoteOpenchamber: {
|
|
mode: DesktopSshRemoteMode;
|
|
keepRunning: boolean;
|
|
preferredPort?: number;
|
|
/** Interface the managed remote server listens on. '0.0.0.0' also exposes it to the remote machine's network. */
|
|
bindHost: '127.0.0.1' | '0.0.0.0';
|
|
installMethod: DesktopSshInstallMethod;
|
|
uploadBundleOverSsh: boolean;
|
|
};
|
|
localForward: {
|
|
preferredLocalPort?: number;
|
|
bindHost: '127.0.0.1' | 'localhost' | '0.0.0.0';
|
|
};
|
|
auth: {
|
|
sshPassword?: DesktopSshStoredSecret;
|
|
openchamberPassword?: DesktopSshStoredSecret;
|
|
};
|
|
portForwards: DesktopSshPortForward[];
|
|
};
|
|
|
|
export type DesktopSshInstancesConfig = {
|
|
instances: DesktopSshInstance[];
|
|
};
|
|
|
|
type DesktopSshPhase =
|
|
| 'idle'
|
|
| 'config_resolved'
|
|
| 'auth_check'
|
|
| 'master_connecting'
|
|
| 'remote_probe'
|
|
| 'installing'
|
|
| 'updating'
|
|
| 'server_detecting'
|
|
| 'server_starting'
|
|
| 'forwarding'
|
|
| 'ready'
|
|
| 'degraded'
|
|
| 'error';
|
|
|
|
export type DesktopSshInstanceStatus = {
|
|
id: string;
|
|
phase: DesktopSshPhase;
|
|
detail?: string;
|
|
localUrl?: string;
|
|
localPort?: number;
|
|
remotePort?: number;
|
|
startedByUs: boolean;
|
|
retryAttempt: number;
|
|
requiresUserAction: boolean;
|
|
updatedAtMs: number;
|
|
};
|
|
|
|
export type DesktopSshImportCandidate = {
|
|
host: string;
|
|
pattern: boolean;
|
|
source: string;
|
|
sshCommand: string;
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
return typeof value === 'object' && value !== null;
|
|
};
|
|
|
|
const readString = (obj: Record<string, unknown>, key: string): string | null => {
|
|
const value = obj[key];
|
|
return typeof value === 'string' ? value : null;
|
|
};
|
|
|
|
const readNumber = (obj: Record<string, unknown>, key: string): number | null => {
|
|
const value = obj[key];
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
};
|
|
|
|
const readBoolean = (obj: Record<string, unknown>, key: string): boolean | null => {
|
|
const value = obj[key];
|
|
return typeof value === 'boolean' ? value : null;
|
|
};
|
|
|
|
const asStringArray = (value: unknown): string[] => {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((item): item is string => typeof item === 'string');
|
|
};
|
|
|
|
const getInvoke = (): DesktopInvoke | null => {
|
|
if (!hasDesktopInvoke()) return null;
|
|
return (command, args) => invokeDesktop(command, args) as Promise<unknown>;
|
|
};
|
|
|
|
const parseStoredSecret = (value: unknown): DesktopSshStoredSecret | undefined => {
|
|
if (!isRecord(value)) return undefined;
|
|
const enabled = readBoolean(value, 'enabled') ?? false;
|
|
const rawStore = readString(value, 'store')?.toLowerCase();
|
|
const store: DesktopSshSecretStore = rawStore === 'settings' ? 'settings' : 'never';
|
|
const rawValue = readString(value, 'value');
|
|
return {
|
|
enabled,
|
|
store,
|
|
...(rawValue ? { value: rawValue } : {}),
|
|
};
|
|
};
|
|
|
|
const parseForwardType = (value: unknown): DesktopSshPortForwardType => {
|
|
return value === 'remote' || value === 'dynamic' ? value : 'local';
|
|
};
|
|
|
|
const parseForward = (value: unknown): DesktopSshPortForward | null => {
|
|
if (!isRecord(value)) return null;
|
|
const id = readString(value, 'id');
|
|
if (!id) return null;
|
|
const enabled = readBoolean(value, 'enabled') ?? true;
|
|
const type = parseForwardType(readString(value, 'type'));
|
|
const localHost = readString(value, 'localHost') || readString(value, 'local_host') || undefined;
|
|
const localPort = readNumber(value, 'localPort') ?? readNumber(value, 'local_port') ?? undefined;
|
|
const remoteHost = readString(value, 'remoteHost') || readString(value, 'remote_host') || undefined;
|
|
const remotePort = readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port') ?? undefined;
|
|
return {
|
|
id,
|
|
enabled,
|
|
type,
|
|
...(localHost ? { localHost } : {}),
|
|
...(typeof localPort === 'number' ? { localPort } : {}),
|
|
...(remoteHost ? { remoteHost } : {}),
|
|
...(typeof remotePort === 'number' ? { remotePort } : {}),
|
|
};
|
|
};
|
|
|
|
const parseInstance = (value: unknown): DesktopSshInstance | null => {
|
|
if (!isRecord(value)) return null;
|
|
const id = readString(value, 'id');
|
|
const sshCommand = readString(value, 'sshCommand') || readString(value, 'ssh_command');
|
|
if (!id || !sshCommand) return null;
|
|
const nickname = readString(value, 'nickname');
|
|
|
|
const parsedRaw = value.sshParsed;
|
|
const parsed = isRecord(parsedRaw)
|
|
? {
|
|
destination: readString(parsedRaw, 'destination') || '',
|
|
args: asStringArray(parsedRaw.args),
|
|
}
|
|
: undefined;
|
|
|
|
const remoteRaw = isRecord(value.remoteOpenchamber)
|
|
? value.remoteOpenchamber
|
|
: isRecord(value.remote_openchamber)
|
|
? value.remote_openchamber
|
|
: {};
|
|
|
|
const localRaw = isRecord(value.localForward)
|
|
? value.localForward
|
|
: isRecord(value.local_forward)
|
|
? value.local_forward
|
|
: {};
|
|
|
|
const authRaw = isRecord(value.auth) ? value.auth : {};
|
|
|
|
const rawMode = readString(remoteRaw, 'mode')?.toLowerCase();
|
|
const mode: DesktopSshRemoteMode = rawMode === 'external' ? 'external' : 'managed';
|
|
|
|
const rawInstallMethod = readString(remoteRaw, 'installMethod') || readString(remoteRaw, 'install_method');
|
|
// Legacy 'download_release'/'upload_bundle' never had their own remote path:
|
|
// they fell through to the same bun-then-npm attempt as 'auto'. Read them as
|
|
// 'auto' so the stored value matches what actually happens.
|
|
const installMethod: DesktopSshInstallMethod =
|
|
rawInstallMethod === 'npm' || rawInstallMethod === 'bun' ? rawInstallMethod : 'auto';
|
|
|
|
const bindHostRaw =
|
|
readString(localRaw, 'bindHost') ||
|
|
readString(localRaw, 'bind_host') ||
|
|
'127.0.0.1';
|
|
const bindHost: '127.0.0.1' | 'localhost' | '0.0.0.0' =
|
|
bindHostRaw === 'localhost' || bindHostRaw === '0.0.0.0' ? bindHostRaw : '127.0.0.1';
|
|
|
|
const forwardsRaw = Array.isArray(value.portForwards)
|
|
? value.portForwards
|
|
: Array.isArray(value.port_forwards)
|
|
? value.port_forwards
|
|
: [];
|
|
|
|
const portForwards = forwardsRaw
|
|
.map((item) => parseForward(item))
|
|
.filter((item): item is DesktopSshPortForward => Boolean(item));
|
|
|
|
const preferredPort = readNumber(remoteRaw, 'preferredPort') ?? readNumber(remoteRaw, 'preferred_port');
|
|
const rawRemoteBindHost = readString(remoteRaw, 'bindHost') || readString(remoteRaw, 'bind_host');
|
|
const remoteBindHost: '127.0.0.1' | '0.0.0.0' = rawRemoteBindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1';
|
|
const preferredLocalPort =
|
|
readNumber(localRaw, 'preferredLocalPort') ?? readNumber(localRaw, 'preferred_local_port');
|
|
const sshPassword = parseStoredSecret(authRaw.sshPassword || authRaw.ssh_password);
|
|
const openchamberPassword = parseStoredSecret(authRaw.openchamberPassword || authRaw.openchamber_password);
|
|
|
|
return {
|
|
id,
|
|
...(nickname ? { nickname } : {}),
|
|
sshCommand,
|
|
...(parsed && parsed.destination ? { sshParsed: parsed } : {}),
|
|
connectionTimeoutSec:
|
|
readNumber(value, 'connectionTimeoutSec') ??
|
|
readNumber(value, 'connection_timeout_sec') ??
|
|
60,
|
|
remoteOpenchamber: {
|
|
mode,
|
|
keepRunning: readBoolean(remoteRaw, 'keepRunning') ?? readBoolean(remoteRaw, 'keep_running') ?? true,
|
|
bindHost: remoteBindHost,
|
|
...(preferredPort ? { preferredPort } : {}),
|
|
installMethod,
|
|
uploadBundleOverSsh:
|
|
readBoolean(remoteRaw, 'uploadBundleOverSsh') ??
|
|
readBoolean(remoteRaw, 'upload_bundle_over_ssh') ??
|
|
false,
|
|
},
|
|
localForward: {
|
|
...(preferredLocalPort ? { preferredLocalPort } : {}),
|
|
bindHost,
|
|
},
|
|
auth: {
|
|
...(sshPassword ? { sshPassword } : {}),
|
|
...(openchamberPassword ? { openchamberPassword } : {}),
|
|
},
|
|
portForwards,
|
|
};
|
|
};
|
|
|
|
const parsePhase = (value: unknown): DesktopSshPhase => {
|
|
switch (value) {
|
|
case 'config_resolved':
|
|
case 'auth_check':
|
|
case 'master_connecting':
|
|
case 'remote_probe':
|
|
case 'installing':
|
|
case 'updating':
|
|
case 'server_detecting':
|
|
case 'server_starting':
|
|
case 'forwarding':
|
|
case 'ready':
|
|
case 'degraded':
|
|
case 'error':
|
|
return value;
|
|
default:
|
|
return 'idle';
|
|
}
|
|
};
|
|
|
|
const parseStatus = (value: unknown): DesktopSshInstanceStatus | null => {
|
|
if (!isRecord(value)) return null;
|
|
const id = readString(value, 'id');
|
|
if (!id) return null;
|
|
return {
|
|
id,
|
|
phase: parsePhase(readString(value, 'phase')),
|
|
...(readString(value, 'detail') ? { detail: readString(value, 'detail') || undefined } : {}),
|
|
...(readString(value, 'localUrl') || readString(value, 'local_url')
|
|
? { localUrl: readString(value, 'localUrl') || readString(value, 'local_url') || undefined }
|
|
: {}),
|
|
...(typeof (readNumber(value, 'localPort') ?? readNumber(value, 'local_port')) === 'number'
|
|
? { localPort: readNumber(value, 'localPort') ?? readNumber(value, 'local_port') ?? undefined }
|
|
: {}),
|
|
...(typeof (readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port')) === 'number'
|
|
? {
|
|
remotePort: readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port') ?? undefined,
|
|
}
|
|
: {}),
|
|
startedByUs: readBoolean(value, 'startedByUs') ?? readBoolean(value, 'started_by_us') ?? false,
|
|
retryAttempt: readNumber(value, 'retryAttempt') ?? readNumber(value, 'retry_attempt') ?? 0,
|
|
requiresUserAction:
|
|
readBoolean(value, 'requiresUserAction') ?? readBoolean(value, 'requires_user_action') ?? false,
|
|
updatedAtMs: readNumber(value, 'updatedAtMs') ?? readNumber(value, 'updated_at_ms') ?? Date.now(),
|
|
};
|
|
};
|
|
|
|
const parseImportCandidate = (value: unknown): DesktopSshImportCandidate | null => {
|
|
if (!isRecord(value)) return null;
|
|
const host = readString(value, 'host');
|
|
const source = readString(value, 'source');
|
|
const sshCommand = readString(value, 'sshCommand') || readString(value, 'ssh_command');
|
|
if (!host || !source || !sshCommand) return null;
|
|
return {
|
|
host,
|
|
source,
|
|
sshCommand,
|
|
pattern: readBoolean(value, 'pattern') ?? false,
|
|
};
|
|
};
|
|
|
|
export const createDesktopSshInstance = (id: string, sshCommand: string): DesktopSshInstance => {
|
|
return {
|
|
id,
|
|
sshCommand,
|
|
connectionTimeoutSec: 60,
|
|
remoteOpenchamber: {
|
|
mode: 'managed',
|
|
keepRunning: true,
|
|
bindHost: '127.0.0.1',
|
|
installMethod: 'auto',
|
|
uploadBundleOverSsh: false,
|
|
},
|
|
localForward: {
|
|
bindHost: '127.0.0.1',
|
|
},
|
|
auth: {},
|
|
portForwards: [],
|
|
};
|
|
};
|
|
|
|
export const desktopSshInstancesGet = async (): Promise<DesktopSshInstancesConfig> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) {
|
|
return { instances: [] };
|
|
}
|
|
|
|
const raw = await invoke('desktop_ssh_instances_get');
|
|
if (!isRecord(raw)) {
|
|
return { instances: [] };
|
|
}
|
|
|
|
const listRaw = Array.isArray(raw.instances)
|
|
? raw.instances
|
|
: Array.isArray(raw.desktopSshInstances)
|
|
? raw.desktopSshInstances
|
|
: [];
|
|
|
|
const instances = listRaw
|
|
.map((item) => parseInstance(item))
|
|
.filter((item): item is DesktopSshInstance => Boolean(item));
|
|
|
|
return { instances };
|
|
};
|
|
|
|
export const desktopSshInstancesSet = async (config: DesktopSshInstancesConfig): Promise<void> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return;
|
|
await invoke('desktop_ssh_instances_set', {
|
|
config: {
|
|
instances: config.instances,
|
|
},
|
|
});
|
|
};
|
|
|
|
export const desktopSshImportHosts = async (): Promise<DesktopSshImportCandidate[]> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return [];
|
|
const raw = await invoke('desktop_ssh_import_hosts');
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw
|
|
.map((item) => parseImportCandidate(item))
|
|
.filter((item): item is DesktopSshImportCandidate => Boolean(item));
|
|
};
|
|
|
|
export const desktopSshConnect = async (id: string): Promise<void> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return;
|
|
await invoke('desktop_ssh_connect', { id });
|
|
};
|
|
|
|
export const desktopSshDisconnect = async (id: string): Promise<void> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return;
|
|
await invoke('desktop_ssh_disconnect', { id });
|
|
};
|
|
|
|
export const desktopSshStatus = async (id?: string): Promise<DesktopSshInstanceStatus[]> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return [];
|
|
const raw = await invoke('desktop_ssh_status', {
|
|
...(id ? { id } : {}),
|
|
});
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw
|
|
.map((item) => parseStatus(item))
|
|
.filter((item): item is DesktopSshInstanceStatus => Boolean(item));
|
|
};
|
|
|
|
export const desktopSshLogs = async (id: string, limit?: number): Promise<string[]> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return [];
|
|
const raw = await invoke('desktop_ssh_logs', {
|
|
id,
|
|
...(typeof limit === 'number' ? { limit } : {}),
|
|
});
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.filter((line): line is string => typeof line === 'string');
|
|
};
|
|
|
|
export const desktopSshLogsClear = async (id: string): Promise<void> => {
|
|
const invoke = getInvoke();
|
|
if (!invoke) return;
|
|
await invoke('desktop_ssh_logs_clear', { id });
|
|
};
|
|
|
|
export const listenDesktopSshStatus = async (
|
|
listener: (status: DesktopSshInstanceStatus) => void,
|
|
): Promise<() => Promise<void>> => {
|
|
if (!hasDesktopInvoke()) {
|
|
return async () => {};
|
|
}
|
|
|
|
const desktop = (window as unknown as { __OPENCHAMBER_DESKTOP__?: DesktopBridgeGlobal }).__OPENCHAMBER_DESKTOP__;
|
|
const listen = desktop?.listen;
|
|
if (typeof listen !== 'function') {
|
|
return async () => {};
|
|
}
|
|
|
|
const unlisten = await listen('openchamber:ssh-instance-status', (event) => {
|
|
const status = parseStatus(event?.payload);
|
|
if (!status) return;
|
|
listener(status);
|
|
});
|
|
|
|
return async () => {
|
|
await unlisten();
|
|
};
|
|
};
|