@linxin666/dsh-ssh 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +371 -461
- package/lib/client.js.map +1 -1
- package/lib/index.js +707 -466
- package/lib/types/client/api.d.ts +55 -4
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/native-panel.d.ts +70 -0
- package/lib/types/client/panel/SshPanel.d.ts +1 -3
- package/lib/types/client/panel/TerminalTab.d.ts +6 -1
- package/lib/types/client/panel/controller.d.ts +71 -4
- package/lib/types/engine/terminal-sessions.d.ts +67 -0
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +12 -1
- package/lib/types/routes.d.ts +2 -0
- package/package.json +19 -16
- package/src/client/api.ts +137 -24
- package/src/client/index.ts +31 -9
- package/src/client/locales.ts +2 -0
- package/src/client/native-panel.tsx +141 -0
- package/src/client/panel/SshPanel.tsx +25 -28
- package/src/client/panel/TerminalTab.tsx +54 -14
- package/src/client/panel/controller.ts +126 -7
- package/src/client/panel/panel.module.css +8 -128
- package/src/engine/terminal-sessions.ts +280 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +6 -1
- package/src/routes.ts +26 -107
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/mount.d.ts +0 -27
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -26
- package/src/client/body-mutations.ts +0 -133
- package/src/client/mount.tsx +0 -47
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -51
|
@@ -7,10 +7,37 @@ import { type ClusterResult, type ExecResult, type HostPayload, type ImportResul
|
|
|
7
7
|
export declare class SshApiError extends Error {
|
|
8
8
|
constructor(message: string);
|
|
9
9
|
}
|
|
10
|
+
/** The location facts a terminal socket URL is built from. */
|
|
11
|
+
interface PageLocation {
|
|
12
|
+
/** Page scheme with its colon (for example `https:` or `dsh-app:`). */
|
|
13
|
+
protocol: string;
|
|
14
|
+
/** Page authority, empty for a scheme that carries none. */
|
|
15
|
+
host: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The WebSocket URL the terminal route is reached at, or undefined when this
|
|
19
|
+
* page cannot carry one.
|
|
20
|
+
*
|
|
21
|
+
* A page delivered by an application on this machine (the official DSH
|
|
22
|
+
* Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
|
|
23
|
+
* transport: its scheme handler forwards HTTP requests to the local host but
|
|
24
|
+
* cannot upgrade a socket, so `ws://app/...` never connects and the terminal
|
|
25
|
+
* reports "connection error" (issue #1744). Every other page is a web page and
|
|
26
|
+
* resolves the socket against its own origin, exactly as before.
|
|
27
|
+
*
|
|
28
|
+
* A blank authority is treated the same as an application scheme: there is
|
|
29
|
+
* nothing to dial, and the caller gets the actionable reason instead of a
|
|
30
|
+
* socket that can only fail.
|
|
31
|
+
*
|
|
32
|
+
* @param location - the page location to read.
|
|
33
|
+
* @param search - the query string carrying `alias` or `session`.
|
|
34
|
+
* @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
|
|
35
|
+
*/
|
|
36
|
+
export declare function terminalSocketUrl(location: PageLocation, search: string): string | undefined;
|
|
10
37
|
/** One open terminal connection (WebSocket JSON frames). */
|
|
11
38
|
export interface TerminalConnection {
|
|
12
|
-
/** Fired on the ready frame (shell is up). */
|
|
13
|
-
onReady: (() => void) | undefined;
|
|
39
|
+
/** Fired on the ready frame (shell is up); carries the host session id and alias. */
|
|
40
|
+
onReady: ((sessionId: string, alias: string) => void) | undefined;
|
|
14
41
|
/** Fired on every output frame. */
|
|
15
42
|
onOutput: ((data: string) => void) | undefined;
|
|
16
43
|
/** Fired on the exit frame (or transport error). */
|
|
@@ -26,7 +53,13 @@ export interface TerminalConnection {
|
|
|
26
53
|
resize(cols: number, rows: number): void;
|
|
27
54
|
/** Send interactive 2FA response codes to the remote server. */
|
|
28
55
|
sendAuthResponse(responses: string[]): void;
|
|
29
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Leave the socket while the host keeps the remote shell alive, so a later
|
|
58
|
+
* {@link SshApi.attachTerminal} picks the session up where it left off.
|
|
59
|
+
* This is what a view does on unmount; it is not a disconnect.
|
|
60
|
+
*/
|
|
61
|
+
detach(): void;
|
|
62
|
+
/** Close the socket and end the remote session. */
|
|
30
63
|
close(): void;
|
|
31
64
|
}
|
|
32
65
|
/** The browser half's only data entry point. */
|
|
@@ -74,6 +107,24 @@ export declare class SshApi {
|
|
|
74
107
|
streamed: boolean;
|
|
75
108
|
bytes: number;
|
|
76
109
|
}>;
|
|
77
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Open a new host terminal session for the alias.
|
|
112
|
+
* @param alias - the configured host alias.
|
|
113
|
+
* @param cols - initial PTY width.
|
|
114
|
+
* @param rows - initial PTY height.
|
|
115
|
+
* @returns the live connection; its ready callback carries the session id
|
|
116
|
+
* {@link attachTerminal} uses after a view detach.
|
|
117
|
+
*/
|
|
78
118
|
openTerminal(alias: string, cols: number, rows: number): TerminalConnection;
|
|
119
|
+
/**
|
|
120
|
+
* Reattach to a host-side session that outlived its view (a panel switch, a
|
|
121
|
+
* page navigation). The host replays its scrollback before the ready frame.
|
|
122
|
+
* @param sessionId - the id the previous connection reported on ready.
|
|
123
|
+
* @param cols - the view's current width.
|
|
124
|
+
* @param rows - the view's current height.
|
|
125
|
+
*/
|
|
126
|
+
attachTerminal(sessionId: string, cols: number, rows: number): TerminalConnection;
|
|
127
|
+
/** One terminal socket over either an alias (open) or a session id (attach). */
|
|
128
|
+
private terminalSocket;
|
|
79
129
|
}
|
|
130
|
+
export {};
|
|
@@ -89,6 +89,7 @@ export declare const zh: {
|
|
|
89
89
|
readonly 'terminal.ready': "终端已连接({alias})";
|
|
90
90
|
readonly 'terminal.exited': "终端已退出({alias})";
|
|
91
91
|
readonly 'terminal.error': "终端错误:{error}";
|
|
92
|
+
readonly 'terminal.noWebSocket': "当前页面不支持 WebSocket(应用外壳只转发 HTTP),请改用浏览器打开的 Web 界面使用终端。";
|
|
92
93
|
readonly 'terminal.auth.title': "二次身份验证 (2FA / 交互式认证)";
|
|
93
94
|
readonly 'terminal.auth.submit': "提交验证码";
|
|
94
95
|
readonly 'terminal.auth.cancel': "取消";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native panel registration for the SSH panel.
|
|
3
|
+
*
|
|
4
|
+
* The panel is an official-style center-column page, not a DOM takeover: it
|
|
5
|
+
* contributes a row into the sidebar shell's own global panel list
|
|
6
|
+
* (`sidebar.panellist`) and its page into the layout's keyed `main` slot. The
|
|
7
|
+
* shell then owns the row box, the label, the active highlight, the collapsed
|
|
8
|
+
* rail and the panel switch, exactly as it does for the shipped Plugins and
|
|
9
|
+
* Schedule pages and for the task board and the skill center.
|
|
10
|
+
*
|
|
11
|
+
* Both registrations go through `ctx.slots.inject`, which fires only once the
|
|
12
|
+
* owning shell entry has declared the seat: load order between this plugin and
|
|
13
|
+
* ui-layout / ui-sidebar therefore does not matter, and neither does a shell
|
|
14
|
+
* that cannot serve the seats (the callback simply never runs).
|
|
15
|
+
*
|
|
16
|
+
* The terminal's PTY shell lives on the host and outlives this page (see
|
|
17
|
+
* engine/terminal-sessions.ts), so the layout unmounting a deselected page
|
|
18
|
+
* costs the view its xterm instance, not the remote session: the terminal tab
|
|
19
|
+
* reattaches by session id when the page mounts again.
|
|
20
|
+
*
|
|
21
|
+
* @module @linxin666/dsh-client-ssh/client
|
|
22
|
+
*/
|
|
23
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
24
|
+
import type { SshApi } from './api.ts';
|
|
25
|
+
import { SSH_PANEL_ID, type PanelController } from './panel/controller.ts';
|
|
26
|
+
import { type TerminalFontSource } from './panel/helpers.ts';
|
|
27
|
+
/** The panel id shared by the sidebar row and the main-slot occupant. */
|
|
28
|
+
export { SSH_PANEL_ID };
|
|
29
|
+
/**
|
|
30
|
+
* The sidebar row glyph the shell asks for at its own size and active state.
|
|
31
|
+
* The shell owns the button, label, tooltip and rail geometry; this component
|
|
32
|
+
* draws only the glyph, like every other panel row.
|
|
33
|
+
*
|
|
34
|
+
* The glyph carries `data-dsh-panel-entry` because it is the only DOM this
|
|
35
|
+
* panel's own code owns inside that shell-owned row: the L2 contract (skins)
|
|
36
|
+
* resolves which row belongs to which plugin through it, since the shell
|
|
37
|
+
* stamps no per-entry hook of its own (see contracts/semantic-attrs-v1.md).
|
|
38
|
+
* @param props - the shell's icon share: square edge and selection state.
|
|
39
|
+
* @returns the decorative terminal glyph.
|
|
40
|
+
*/
|
|
41
|
+
export declare function SshPanelIcon({ size }: {
|
|
42
|
+
size: number;
|
|
43
|
+
active: boolean;
|
|
44
|
+
}): React.ReactElement;
|
|
45
|
+
/**
|
|
46
|
+
* The main-slot page. The layout mounts it only while this panel is selected;
|
|
47
|
+
* the wrapper carries the pinned `data-dsh-ssh-view` semantic anchor (L2
|
|
48
|
+
* contract, skins) the takeover container used to own.
|
|
49
|
+
* @param props - the framework main-slot share plus this entry's injected face.
|
|
50
|
+
* @returns the SSH panel page.
|
|
51
|
+
*/
|
|
52
|
+
export declare function SshPanelPage({ controller, api, terminalFont }: {
|
|
53
|
+
controller: PanelController;
|
|
54
|
+
api: SshApi;
|
|
55
|
+
terminalFont?: TerminalFontSource;
|
|
56
|
+
}): React.ReactElement;
|
|
57
|
+
/**
|
|
58
|
+
* Register the SSH panel's sidebar row and center-column page.
|
|
59
|
+
*
|
|
60
|
+
* Both seats are declared by shell plugins this package does not depend on at
|
|
61
|
+
* runtime, so each registration is wrapped in `ctx.slots.inject`: the callback
|
|
62
|
+
* runs only after the owning entry declares the seat, and a shell that never
|
|
63
|
+
* declares it leaves the panel simply absent instead of failing boot.
|
|
64
|
+
* @param ctx - client root context (services: slots).
|
|
65
|
+
* @param controller - the controller the page and the row drive.
|
|
66
|
+
* @param api - the SSH API client the page operates through.
|
|
67
|
+
* @param terminalFont - live terminal-font setting source (issue #577).
|
|
68
|
+
* @returns disposer releasing both registrations.
|
|
69
|
+
*/
|
|
70
|
+
export declare function registerSshPanel(ctx: ClientContext, controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource): () => void;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { SshApi } from '../api.ts';
|
|
2
2
|
import type { PanelController } from './controller.ts';
|
|
3
3
|
import { type TerminalFontSource } from './helpers.ts';
|
|
4
|
-
/** The panel's tab identifiers. */
|
|
5
|
-
export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster';
|
|
6
4
|
/** Panel shell props. */
|
|
7
5
|
export interface SshPanelProps {
|
|
8
|
-
/** The panel state owner (open/close/toggle). */
|
|
6
|
+
/** The panel state owner (open/close/toggle, active tab, terminal session). */
|
|
9
7
|
controller: PanelController;
|
|
10
8
|
/** The SSH API client every tab operates through. */
|
|
11
9
|
api: SshApi;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { SshApi } from '../api.ts';
|
|
2
|
+
import type { PanelController } from './controller.ts';
|
|
2
3
|
import { type TerminalFontSource } from './helpers.ts';
|
|
3
4
|
/** Terminal tab props. */
|
|
4
5
|
export interface TerminalTabProps {
|
|
5
6
|
api: SshApi;
|
|
7
|
+
/** The panel controller that owns the live host session id across mounts. */
|
|
8
|
+
controller: PanelController;
|
|
9
|
+
/** Host session to reattach to on mount (survives a panel switch). */
|
|
10
|
+
sessionId?: string;
|
|
6
11
|
/** Alias preselected by a "connect" action from the hosts tab. */
|
|
7
12
|
presetAlias?: string;
|
|
8
13
|
/** Monotonic id of the connect request (re-applies presetAlias). */
|
|
@@ -14,4 +19,4 @@ export interface TerminalTabProps {
|
|
|
14
19
|
terminalFont?: TerminalFontSource;
|
|
15
20
|
}
|
|
16
21
|
/** The xterm terminal view. */
|
|
17
|
-
export declare function TerminalTab({ api, presetAlias, requestId, terminalFont }: TerminalTabProps): import("react").JSX.Element;
|
|
22
|
+
export declare function TerminalTab({ api, controller, sessionId, presetAlias, requestId, terminalFont }: TerminalTabProps): import("react").JSX.Element;
|
|
@@ -1,22 +1,89 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH panel controller: the single owner of the panel's
|
|
2
|
+
* SSH panel controller: the single owner of the panel's view state.
|
|
3
3
|
*
|
|
4
4
|
* Framework-free (structural runtime faces, task-board core/controller.ts
|
|
5
|
-
* style) so the
|
|
5
|
+
* style) so the sidebar row and the React panel share one tiny subscription
|
|
6
6
|
* surface. The state lives only for the browser session (no persistence).
|
|
7
|
+
*
|
|
8
|
+
* The active tab, the hosts-tab connect request and the live terminal session
|
|
9
|
+
* id live here rather than inside the panel components: the layout mounts a
|
|
10
|
+
* keyed page only while its panel is selected, so component-local state would
|
|
11
|
+
* reset the tab on every panel switch and lose the id a reattach needs.
|
|
7
12
|
*/
|
|
13
|
+
/** The panel's tab identifiers. */
|
|
14
|
+
export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster';
|
|
15
|
+
/** A "connect" action from the hosts tab, handed to the terminal tab. */
|
|
16
|
+
export interface ConnectRequest {
|
|
17
|
+
alias: string;
|
|
18
|
+
/** Monotonic id so a repeated connect for the same alias still re-applies. */
|
|
19
|
+
nonce: number;
|
|
20
|
+
}
|
|
21
|
+
/** Stable id shared by the sidebar panel row and the main-slot page. */
|
|
22
|
+
export declare const SSH_PANEL_ID = "ssh";
|
|
8
23
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
9
24
|
export interface PanelControllerSnapshot {
|
|
25
|
+
/** Whether the center column shows this panel. */
|
|
10
26
|
panelOpen: boolean;
|
|
27
|
+
/** The tab the panel is showing. */
|
|
28
|
+
activeTab: SshTab;
|
|
29
|
+
/** Pending hosts-tab connect request, or null. */
|
|
30
|
+
connectRequest: ConnectRequest | null;
|
|
31
|
+
/** Host-side terminal session the terminal tab should reattach to, if any. */
|
|
32
|
+
terminalSessionId: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The layout's panel-navigation face: selecting the panel is the layout's
|
|
36
|
+
* business, and this is the one call that asks for it (null returns the column
|
|
37
|
+
* to the conversation). Optional, so a shell-less composition keeps the pure
|
|
38
|
+
* state transitions testable.
|
|
39
|
+
*/
|
|
40
|
+
export interface PanelNavigationFace {
|
|
41
|
+
/** Select this panel, or null to hand the column back to the conversation. */
|
|
42
|
+
select(panelId: string | null): void;
|
|
43
|
+
}
|
|
44
|
+
/** Controller dependencies. */
|
|
45
|
+
export interface PanelControllerDeps {
|
|
46
|
+
/** Layout panel selection; absent keeps the controller state-only. */
|
|
47
|
+
panel?: PanelNavigationFace;
|
|
11
48
|
}
|
|
12
|
-
/** The panel state owner the sidebar
|
|
49
|
+
/** The panel state owner the sidebar row and the view both read. */
|
|
13
50
|
export declare class PanelController {
|
|
51
|
+
private readonly deps;
|
|
14
52
|
private panelOpen;
|
|
53
|
+
private activeTab;
|
|
54
|
+
private connectRequest;
|
|
55
|
+
private terminalSessionId;
|
|
15
56
|
private listeners;
|
|
57
|
+
/** Cached so getSnapshot is referentially stable between changes. */
|
|
58
|
+
private snapshot;
|
|
59
|
+
constructor(deps?: PanelControllerDeps);
|
|
16
60
|
getSnapshot(): PanelControllerSnapshot;
|
|
17
61
|
subscribe(fn: () => void): () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Show the panel. The layout owns which panel the column renders, so the
|
|
64
|
+
* state flip and the selection travel together; the selection is requested
|
|
65
|
+
* after the snapshot flips, so a subscriber never observes "open" while the
|
|
66
|
+
* shell still shows the conversation.
|
|
67
|
+
*/
|
|
18
68
|
open(): void;
|
|
69
|
+
/** Return the column to the conversation, asking the layout explicitly. */
|
|
19
70
|
close(): void;
|
|
20
71
|
toggle(): void;
|
|
21
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Reflect a selection made outside this controller (the user clicked another
|
|
74
|
+
* sidebar row, or the layout dropped the panel id).
|
|
75
|
+
* @param panelId - the layout's current panel id, or null for the conversation.
|
|
76
|
+
*/
|
|
77
|
+
syncPanelSelection(panelId: string | null): void;
|
|
78
|
+
/** Switch tabs, keeping every other piece of view state. */
|
|
79
|
+
setActiveTab(tab: SshTab): void;
|
|
80
|
+
/** Hand the terminal tab a host to connect to (hosts-tab "connect" action). */
|
|
81
|
+
requestConnect(alias: string): void;
|
|
82
|
+
/** Record the host-side session so a later page mount can reattach. */
|
|
83
|
+
setTerminalSession(sessionId: string): void;
|
|
84
|
+
/** Forget the session (it exited, or the user disconnected). */
|
|
85
|
+
clearTerminalSession(): void;
|
|
86
|
+
private commit;
|
|
87
|
+
/** Ask the layout to select a panel; a shell without the face is a no-op. */
|
|
88
|
+
private selectPanel;
|
|
22
89
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
import type { KeyboardInteractiveHandler } from './connection-pool.ts';
|
|
3
|
+
import type { ShellSession } from './pty.ts';
|
|
4
|
+
/** What the registry needs from the engine (swappable in tests). */
|
|
5
|
+
export interface TerminalSessionDeps {
|
|
6
|
+
/** Open one PTY shell for the alias (engine.openShell). */
|
|
7
|
+
openShell(alias: string, size: {
|
|
8
|
+
cols: number;
|
|
9
|
+
rows: number;
|
|
10
|
+
}, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>;
|
|
11
|
+
/** @returns the current time (defaults to Date.now). */
|
|
12
|
+
now?: () => number;
|
|
13
|
+
/** Bounded scrollback per session. */
|
|
14
|
+
maxBufferBytes?: number;
|
|
15
|
+
/** Idle window before a detached session is reaped. */
|
|
16
|
+
idleMs?: number;
|
|
17
|
+
/** Grace window an exited session is kept for reattaching views. */
|
|
18
|
+
exitGraceMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The host's terminal session table: open one session per connect, attach any
|
|
22
|
+
* number of sockets, and keep the shell alive across detach.
|
|
23
|
+
*/
|
|
24
|
+
export declare class TerminalSessionRegistry {
|
|
25
|
+
private readonly sessions;
|
|
26
|
+
private readonly deps;
|
|
27
|
+
private readonly maxBufferBytes;
|
|
28
|
+
private readonly idleMs;
|
|
29
|
+
private readonly exitGraceMs;
|
|
30
|
+
constructor(deps: TerminalSessionDeps);
|
|
31
|
+
/** Open a new session for the alias and attach the requesting socket to it. */
|
|
32
|
+
open(ws: WebSocket, alias: string, size: {
|
|
33
|
+
cols: number;
|
|
34
|
+
rows: number;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Attach a socket to an existing session, replaying its scrollback first.
|
|
38
|
+
* @returns false when the session is unknown (expired or already reaped).
|
|
39
|
+
*/
|
|
40
|
+
attach(ws: WebSocket, sessionId: string, size: {
|
|
41
|
+
cols: number;
|
|
42
|
+
rows: number;
|
|
43
|
+
}): boolean;
|
|
44
|
+
/** End every session (host teardown, plugin dispose). */
|
|
45
|
+
dispose(): void;
|
|
46
|
+
/** Number of live sessions (introspection for tests and diagnostics). */
|
|
47
|
+
size(): number;
|
|
48
|
+
/** Wire one socket's frames and lifecycle onto a session. */
|
|
49
|
+
private bind;
|
|
50
|
+
/** Remove a socket from its session, keeping the shell alive. */
|
|
51
|
+
private detach;
|
|
52
|
+
/** End the session a socket is attached to (explicit close from the view). */
|
|
53
|
+
private end;
|
|
54
|
+
/** Mark a session exited and keep it briefly so a reattaching view sees it. */
|
|
55
|
+
private finish;
|
|
56
|
+
/** Append output to the replay buffer, trimmed to the byte cap. */
|
|
57
|
+
private pushOutput;
|
|
58
|
+
/** Broadcast one frame to every attached socket, honoring backpressure. */
|
|
59
|
+
private send;
|
|
60
|
+
private sendTo;
|
|
61
|
+
/** Pause while any attached socket is backed up; resume once all drained. */
|
|
62
|
+
private applyBackpressure;
|
|
63
|
+
/** Answer a keyboard-interactive challenge through whoever is attached. */
|
|
64
|
+
private keyboardInteractive;
|
|
65
|
+
private scheduleReap;
|
|
66
|
+
private clearReap;
|
|
67
|
+
}
|
|
@@ -4,19 +4,50 @@
|
|
|
4
4
|
* the loader accepts a standalone install of the same package side by side;
|
|
5
5
|
* without this guard the second instance would still re-register the same
|
|
6
6
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
-
* and fail the boot. mountOnce makes
|
|
8
|
-
*
|
|
9
|
-
* package name in the client module host).
|
|
7
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
8
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
9
|
+
* deduped by package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
12
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
13
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
14
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
15
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
16
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
17
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
18
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
19
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
20
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
21
|
+
* restart recovered it).
|
|
22
|
+
*
|
|
23
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
24
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
25
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
26
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
27
|
+
* dedupes against it.
|
|
10
28
|
*
|
|
11
29
|
* The registry rides a global symbol so two module instances of the same
|
|
12
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
13
|
-
*
|
|
30
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
31
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
32
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
33
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
34
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
35
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
36
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
37
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
38
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
39
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
40
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
41
|
+
* mounted after it failed with "claims.get is not a function".
|
|
42
|
+
*
|
|
43
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
44
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
45
|
*/
|
|
16
46
|
/**
|
|
17
47
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
-
* The first mount registers normally and
|
|
19
|
-
*
|
|
48
|
+
* The first mount registers normally and releases the name when its fiber
|
|
49
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
50
|
+
* then runs, unless its own fiber disposes first.
|
|
20
51
|
* @param packageName - npm package identity shared by every install source.
|
|
21
52
|
* @param fn - the original plugin apply.
|
|
22
53
|
* @returns an apply of the same shape.
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -172,9 +172,12 @@ export interface ApiErrorBody {
|
|
|
172
172
|
error: string;
|
|
173
173
|
}
|
|
174
174
|
/** WebSocket terminal protocol frames (host -> client and client -> host). */
|
|
175
|
-
export type TerminalServerFrame =
|
|
175
|
+
export type TerminalServerFrame =
|
|
176
|
+
/** The shell is up; `sessionId` reattaches to it after a view detach. */
|
|
177
|
+
{
|
|
176
178
|
type: 'ready';
|
|
177
179
|
alias: string;
|
|
180
|
+
sessionId: string;
|
|
178
181
|
} | {
|
|
179
182
|
type: 'output';
|
|
180
183
|
data: string;
|
|
@@ -201,6 +204,14 @@ export type TerminalClientFrame = {
|
|
|
201
204
|
} | {
|
|
202
205
|
type: 'auth_response';
|
|
203
206
|
responses: string[];
|
|
207
|
+
}
|
|
208
|
+
/** Leave the socket while the host keeps the shell alive for a reattach. */
|
|
209
|
+
| {
|
|
210
|
+
type: 'detach';
|
|
211
|
+
}
|
|
212
|
+
/** End the session outright (the view's disconnect control). */
|
|
213
|
+
| {
|
|
214
|
+
type: 'close';
|
|
204
215
|
};
|
|
205
216
|
/** Route paths the client calls (shared literals). */
|
|
206
217
|
export declare const SSH_API_BASE: "/api/dsh-ssh";
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
10
10
|
import type { SshEngine } from './engine.ts';
|
|
11
|
+
import { TerminalSessionRegistry } from './engine/terminal-sessions.ts';
|
|
11
12
|
import type { HostStore } from './store.ts';
|
|
12
13
|
/** Route family dependencies. */
|
|
13
14
|
export interface SshRoutesDeps {
|
|
@@ -28,4 +29,5 @@ export interface SshRoutesDeps {
|
|
|
28
29
|
export declare function makeRoutes(deps: SshRoutesDeps): {
|
|
29
30
|
routes: WebRoute[];
|
|
30
31
|
upgrade: WebUpgradeRoute;
|
|
32
|
+
terminalSessions: TerminalSessionRegistry;
|
|
31
33
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.4",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dsh": {
|
|
29
29
|
"engines": {
|
|
30
|
-
"dsh": ">=0.
|
|
30
|
+
"dsh": ">=0.2.0-rc.1"
|
|
31
31
|
},
|
|
32
32
|
"bundle": {
|
|
33
33
|
"patch": "./cordis.patch.yml"
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"inject": [
|
|
37
37
|
"@deepseek-ai/dsh-client-locale",
|
|
38
38
|
"@deepseek-ai/dsh-client-ui-renderer",
|
|
39
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
40
41
|
],
|
|
41
42
|
"platform": "web"
|
|
42
43
|
}
|
|
@@ -49,24 +50,26 @@
|
|
|
49
50
|
"ws": "^8.18.0"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
|
-
"@deepseek-ai/dsh": ">=0.
|
|
53
|
+
"@deepseek-ai/dsh": ">=0.2.0-rc.1",
|
|
53
54
|
"react": "^18.2.0",
|
|
54
55
|
"react-dom": "^18.2.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
58
|
-
"@deepseek-ai/dsh-client-connection": "^0.
|
|
59
|
-
"@deepseek-ai/dsh-client-locale": "^0.
|
|
60
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.
|
|
61
|
-
"@deepseek-ai/dsh-client-ui-
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-
|
|
63
|
-
"@deepseek-ai/dsh-
|
|
64
|
-
"@deepseek-ai/dsh-
|
|
65
|
-
"@deepseek-ai/dsh-
|
|
66
|
-
"@deepseek-ai/dsh-
|
|
67
|
-
"@deepseek-ai/dsh-
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
69
|
-
"@deepseek-ai/dsh-
|
|
59
|
+
"@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-scope": "^0.2.0-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-timeout": "^0.2.0-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
|
|
70
73
|
"@types/node": "^22.20.0",
|
|
71
74
|
"@types/react": "~18.3.1",
|
|
72
75
|
"@types/react-dom": "^18.3.5",
|