@tiphareth/dsh-hardssh 0.1.2 → 0.2.2

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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +58 -25
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,123 +0,0 @@
1
- /**
2
- * Sidebar entry injection.
3
- *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so — following the task-board precedent of DOM-level extension — the entry
6
- * row is injected between the shell's New Session button and the workspace
7
- * browser. The injection self-heals: a MutationObserver watches the sidebar
8
- * root and re-inserts the row whenever a React re-render displaces it
9
- * (re-insertion happens in the same frame, before paint, so no flicker).
10
- *
11
- * The row is plain DOM (no React tree) so it can never disturb the shell's
12
- * reconciliation; the panel view it toggles is a separate React root mounted
13
- * in the center column (see mount.tsx).
14
- */
15
- import type { PanelController } from './panel/controller.ts'
16
- import { tt } from './panel/helpers.ts'
17
- import css from './panel/panel.module.css'
18
-
19
- /** Stable data attribute identifying the injected entry row. */
20
- export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
21
-
22
- /** The sidebar column is the grid item AppFrame renders with this pane marker. */
23
- const SIDEBAR_COLUMN_SELECTOR = '[data-pane="sidebar"]'
24
-
25
- /** Inline icon (matches the shell's 16px nav-icon look): a terminal prompt glyph. */
26
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M4.5 5.5l2.5 2.5-2.5 2.5"/><path d="M8.5 10.5h3"/></svg>'
27
-
28
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
29
- function sidebarRoot(): HTMLElement | undefined {
30
- const column = document.querySelector<HTMLElement>(SIDEBAR_COLUMN_SELECTOR)
31
- const root = column?.firstElementChild as HTMLElement | undefined
32
- return root ?? undefined
33
- }
34
-
35
- /** The New Session button: the shell's only direct-child button of the root. */
36
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
37
- for (const child of root.children) {
38
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
39
- }
40
- return undefined
41
- }
42
-
43
- /** Build the entry row (a detached button; insert once the shell is up). */
44
- function createEntry(controller: PanelController): HTMLButtonElement {
45
- const entry = document.createElement('button')
46
- entry.type = 'button'
47
- entry.dataset.dshSshEntry = ''
48
- entry.className = css.entry
49
- entry.setAttribute('aria-label', tt('entry.label'))
50
- entry.setAttribute('title', tt('entry.tooltip'))
51
- entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + tt('entry.label') + '</span>'
52
- entry.addEventListener('click', () => { controller.toggle() })
53
- return entry
54
- }
55
-
56
- /** Re-insert the entry after the New Session button (before the browser region). */
57
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
58
- const button = newSessionButton(root)
59
- if (button === undefined) return false
60
- if (entry.parentElement !== root) {
61
- // Insert after the button: React never manages this node, and the shell
62
- // keeps its own child order intact around it.
63
- root.insertBefore(entry, button.nextElementSibling)
64
- }
65
- return true
66
- }
67
-
68
- /**
69
- * Mount the sidebar entry, waiting for the shell to render and self-healing
70
- * on later React re-renders.
71
- * @param controller - the panel controller the entry toggles.
72
- * @returns disposer removing the entry and its observers.
73
- */
74
- export function mountSidebarEntry(controller: PanelController): () => void {
75
- const entry = createEntry(controller)
76
- let root: HTMLElement | undefined
77
- let placed = false
78
-
79
- const tryPlace = (): void => {
80
- if (placed) return
81
- if (root !== undefined && !root.isConnected) {
82
- // The shell re-created the sidebar pane; re-query from scratch.
83
- rootObserver.disconnect()
84
- root = undefined
85
- }
86
- root ??= sidebarRoot()
87
- if (root === undefined) return
88
- placed = placeEntry(root, entry)
89
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
90
- }
91
-
92
- // The shell renders after boot settlement; watch for its arrival.
93
- const waitObserver = new MutationObserver(() => { tryPlace() })
94
- waitObserver.observe(document.body, { childList: true, subtree: true })
95
-
96
- // Self-heal: if a React re-render displaces the row, re-insert it in the
97
- // same frame (microtask before paint -> no visible flicker).
98
- const rootObserver = new MutationObserver(() => {
99
- if (root === undefined || !root.isConnected) {
100
- placed = false
101
- tryPlace()
102
- return
103
- }
104
- if (!root.contains(entry)) {
105
- placed = placeEntry(root, entry)
106
- }
107
- })
108
-
109
- // Reflect the panel's open state on the row (active highlight).
110
- const unsubscribe = controller.subscribe(() => {
111
- entry.dataset.active = controller.getSnapshot().panelOpen ? 'true' : undefined
112
- })
113
- entry.dataset.active = controller.getSnapshot().panelOpen ? 'true' : undefined
114
-
115
- tryPlace()
116
-
117
- return () => {
118
- waitObserver.disconnect()
119
- rootObserver.disconnect()
120
- unsubscribe()
121
- entry.remove()
122
- }
123
- }
@@ -1,225 +0,0 @@
1
- /**
2
- * Sidebar workspace-row click gate: when the operator clicks an SSH-bound
3
- * workspace row in the session sidebar, ensure the server connection can
4
- * actually be established BEFORE the shell proceeds with the native open —
5
- * otherwise the shell surfaces raw SSH errors (host key untrusted, session
6
- * password missing) and the workspace appears "dead" with no prompt.
7
- *
8
- * The gate runs the engine's /test probe (a real, lightweight connect):
9
- * - HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH → the host fingerprint confirm
10
- * dialog (HostFingerprintDialog; TOFU first encounter / key rotation);
11
- * - NEEDS_PASSWORD / NEEDS_PASSPHRASE → the VSCode-style session password
12
- * dialog (SessionSecretDialog; never persisted, session-only);
13
- * - ok → mark the alias as gated (5-minute TTL — trust is durable and the
14
- * session password survives in the engine table) and re-dispatch the click.
15
- *
16
- * Pure DOM-level extension following the workspace-badges precedent: the
17
- * sidebar shell exposes no row slot, so a MutationObserver self-heals when
18
- * React re-renders displace the injected listener. A capture-phase listener
19
- * on the row blocks the shell's click while a dialog is needed, then lets
20
- * clicks through once the alias has passed the gate.
21
- *
22
- * @module dsh-hardssh/client/workspace-gate
23
- */
24
- import { createElement, type ReactElement } from 'react'
25
- import { createRoot } from 'react-dom/client'
26
- import type { SshApi } from './ssh/api.ts'
27
- import { SshApiError } from './ssh/api.ts'
28
- import { HostFingerprintDialog } from './ssh/panel/HostFingerprintDialog.tsx'
29
- import { SessionSecretDialog } from './ssh/panel/SessionSecretDialog.tsx'
30
- import { tt } from './text.ts'
31
-
32
- const PROJECT_ROW_SELECTOR = '[class*="projectRow"]'
33
- const TITLE_SELECTOR = '[class*="title"]'
34
- /** A passed gate stays valid this long (trust is durable; the session
35
- * password table lives in the host process for the session). */
36
- const GATE_TTL_MS = 5 * 60 * 1000
37
- /** Cap on interactive retry rounds (wrong password loops etc.). */
38
- const MAX_ATTEMPTS = 3
39
-
40
- /** alias → last successful gate timestamp (browser-session scope). */
41
- const gatePassedAt = new Map<string, number>()
42
- /** alias → in-flight gate promise (no duplicate dialogs on multi-click). */
43
- const pending = new Map<string, Promise<boolean>>()
44
- /** alias → SSH login name (fetched once for the user@alias prompt). */
45
- let hostsCache: Map<string, string> | null = null
46
-
47
- async function hostsUser(api: SshApi, alias: string): Promise<string | undefined> {
48
- if (hostsCache === null) {
49
- try {
50
- const list = await api.listHosts()
51
- hostsCache = new Map(list.map(host => [host.alias, host.user]))
52
- } catch {
53
- hostsCache = new Map()
54
- }
55
- }
56
- return hostsCache.get(alias)
57
- }
58
-
59
- function gateActive(alias: string, now: number): boolean {
60
- const at = gatePassedAt.get(alias)
61
- return at !== undefined && now - at < GATE_TTL_MS
62
- }
63
-
64
- /** Mount a dialog component into a fresh portal root; returns its closer.
65
- * The dialog's onClose prop is forced to the closer so cancel works. */
66
- function mountModal(node: ReactElement): () => void {
67
- const host = document.createElement('div')
68
- host.dataset.sshGateDialog = ''
69
- document.body.appendChild(host)
70
- const root = createRoot(host)
71
- const close = (): void => {
72
- root.unmount()
73
- host.remove()
74
- }
75
- root.render(createElement(node.type, { ...node.props, onClose: close }))
76
- return close
77
- }
78
-
79
- /** Transient inline error under the clicked row (removed after 8 s).
80
- * A null row means the caller owns error surfacing — stay silent. */
81
- function showBanner(row: HTMLElement | null, alias: string, message: string): void {
82
- if (row === null) return
83
- const banner = document.createElement('div')
84
- banner.dataset.sshGateError = ''
85
- banner.textContent = `${tt('gate.failed', alias)}: ${message}`
86
- banner.setAttribute(
87
- 'style',
88
- 'margin:2px 4px;padding:6px 8px;font-size:12px;color:#b00020;background:rgba(176,0,32,.08);border-radius:4px;white-space:normal;line-height:1.5',
89
- )
90
- row.insertAdjacentElement('afterend', banner)
91
- setTimeout(() => { banner.remove() }, 8000)
92
- }
93
-
94
- /** Interactive prompts, resolving true when the user finished the action. */
95
- function promptFingerprint(api: SshApi, alias: string, fingerprintSha256: string, mismatch: boolean): Promise<boolean> {
96
- return new Promise((resolve) => {
97
- const close = mountModal(createElement(HostFingerprintDialog, {
98
- api,
99
- alias,
100
- fingerprintSha256,
101
- mismatch,
102
- onClose: () => { resolve(false) },
103
- onTrusted: () => { close(); resolve(true) },
104
- }))
105
- })
106
- }
107
-
108
- function promptSecret(api: SshApi, alias: string, secret: 'password' | 'passphrase'): Promise<boolean> {
109
- return new Promise((resolve) => {
110
- let closed = false
111
- const finish = (ok: boolean): void => {
112
- if (closed) return
113
- closed = true
114
- resolve(ok)
115
- }
116
- void hostsUser(api, alias).then((user) => {
117
- const close = mountModal(createElement(SessionSecretDialog, {
118
- api,
119
- alias,
120
- user,
121
- secret,
122
- onClose: () => { finish(false) },
123
- onProvided: () => { close(); finish(true) },
124
- }))
125
- })
126
- })
127
- }
128
-
129
- /**
130
- * Probe + prompt until connected. Resolves true when the alias may proceed
131
- * (the gate passed); false when the user cancelled or the failure is not
132
- * interactive (banner left behind).
133
- */
134
- function ensureConnected(api: SshApi, alias: string, row: HTMLElement | null): Promise<boolean> {
135
- const now = Date.now()
136
- if (gateActive(alias, now)) return Promise.resolve(true)
137
- const inFlight = pending.get(alias)
138
- if (inFlight !== undefined) return inFlight
139
-
140
- const attempt = (async (): Promise<boolean> => {
141
- for (let i = 0; i < MAX_ATTEMPTS; i++) {
142
- try {
143
- const result = await api.testHost(alias)
144
- if (result.ok) {
145
- gatePassedAt.set(alias, Date.now())
146
- return true
147
- }
148
- if (result.code === 'NEEDS_PASSWORD' && (result.secret === 'password' || result.secret === 'passphrase')) {
149
- if (await promptSecret(api, alias, result.secret)) continue
150
- return false
151
- }
152
- showBanner(row, alias, result.error ?? 'connection failed')
153
- return false
154
- } catch (cause) {
155
- if (cause instanceof SshApiError && (cause.code === 'HOST_KEY_UNKNOWN' || cause.code === 'HOST_KEY_MISMATCH')) {
156
- const fingerprint = cause.hostKeyFingerprint ?? ''
157
- if (fingerprint !== '' && await promptFingerprint(api, alias, fingerprint, cause.code === 'HOST_KEY_MISMATCH')) continue
158
- if (fingerprint === '') showBanner(row, alias, cause.message)
159
- return false
160
- }
161
- showBanner(row, alias, cause instanceof Error ? cause.message : String(cause))
162
- return false
163
- }
164
- }
165
- showBanner(row, alias, tt('gate.retryLimit'))
166
- return false
167
- })()
168
-
169
- pending.set(alias, attempt)
170
- void attempt.finally(() => { pending.delete(alias) })
171
- return attempt
172
- }
173
-
174
- /** Silent prompt-only connection gate (no row / banner) for flows that
175
- * surface their own errors (e.g. the directory browser). */
176
- export function connectHost(api: SshApi, alias: string): Promise<boolean> {
177
- return ensureConnected(api, alias, null)
178
- }
179
-
180
- /**
181
- * Mount the click gate over every sidebar workspace row whose title belongs
182
- * to an SSH-bound workspace. Self-heals via MutationObserver. Returns a
183
- * disposer that removes listeners and injected state.
184
- */
185
- export function mountWorkspaceGates(
186
- workspaces: Array<{ id: string; title: string; alias: string }>,
187
- api: SshApi,
188
- ): () => void {
189
- const byTitle = new Map<string, string>()
190
- for (const workspace of workspaces) byTitle.set(workspace.title, workspace.alias)
191
-
192
- const attach = (row: HTMLElement): void => {
193
- if (row.dataset.sshGate !== undefined) return
194
- const titleEl = row.querySelector<HTMLElement>(TITLE_SELECTOR)
195
- if (titleEl === null || titleEl === undefined) return
196
- const alias = byTitle.get(titleEl.textContent?.trim() ?? '')
197
- if (alias === undefined) return
198
- row.dataset.sshGate = '1'
199
- row.addEventListener('click', (event: MouseEvent) => {
200
- if (gateActive(alias, Date.now())) return // passed: let the native click through
201
- event.preventDefault()
202
- event.stopPropagation()
203
- void ensureConnected(api, alias, row).then((ok) => {
204
- if (ok) row.click() // re-dispatch; the guard now lets it through
205
- })
206
- }, true)
207
- }
208
-
209
- const scan = (): void => {
210
- for (const row of document.querySelectorAll<HTMLElement>(PROJECT_ROW_SELECTOR)) {
211
- attach(row)
212
- }
213
- }
214
-
215
- const observer = new MutationObserver(() => { scan() })
216
- observer.observe(document.body, { childList: true, subtree: true })
217
- scan()
218
-
219
- return () => {
220
- observer.disconnect()
221
- for (const row of document.querySelectorAll<HTMLElement>('[data-ssh-gate]')) {
222
- delete row.dataset.sshGate
223
- }
224
- }
225
- }
@@ -1,201 +0,0 @@
1
- /**
2
- * Remote-workspace runner: lets other plugins (dsh-workbench-tiphareth) run
3
- * git/files/terminals against an SSH-bound workspace. The workbench host asks
4
- * `resolveRemote(root)` for a local anchor path; a hit returns the remote
5
- * context + callable channels (git exec via the engine, SFTP ops, PTY).
6
- * Kept duck-typed so the workbench never needs a compile-time dependency.
7
- */
8
-
9
- import type { SshEngine } from './ssh/engine.ts'
10
- import type { SshWorkspaceLedger } from './ledger.ts'
11
-
12
- /** The remote context one SSH-bound workspace resolves to. */
13
- export interface RemoteWorkspaceContext {
14
- alias: string
15
- remoteRoot: string
16
- /** Run `git [args...]` inside remoteRoot. Mirrors runGit's result shape. */
17
- git(args: readonly string[], opts?: { timeoutMs?: number; input?: string; allowNonZero?: boolean }): Promise<{ stdout: string; stderr: string; exitCode: number }>
18
- /** Run one arbitrary command (used by tools that shell out). */
19
- run(command: string, opts?: { timeoutMs?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>
20
- /** SFTP operations. */
21
- list(path: string): Promise<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>>
22
- stat(path: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }>
23
- readFile(path: string): Promise<{ content: Buffer; mtime: number; size: number }>
24
- writeFile(path: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }>
25
- mkdir(path: string): Promise<void>
26
- rm(path: string, recursive: boolean): Promise<void>
27
- rename(from: string, to: string): Promise<void>
28
- /** Open an interactive SSH PTY in remoteRoot (columns/rows for the channel). */
29
- openTerminal(cols: number, rows: number): Promise<RemotePtyHandle>
30
- }
31
-
32
- /** Minimal PTY handle (duck-typed for consumers like dsh-workbench-tiphareth). */
33
- export interface RemotePtyHandle {
34
- shell: string
35
- write(data: string): void
36
- resize(cols: number, rows: number): void
37
- kill(): void
38
- onData(handler: (data: string) => void): { dispose(): void }
39
- onExit(handler: (event: { exitCode: number }) => void): { dispose(): void }
40
- }
41
-
42
- /** Last-resort bound on opening a remote PTY (fresh SSH connect + shell). */
43
- const OPEN_SHELL_TIMEOUT_MS = 30_000
44
-
45
- /** Reject `promise` after `ms` with a clear message (unref'd timer). */
46
- function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
47
- return new Promise<T>((resolve, reject) => {
48
- const timer = setTimeout(() => reject(new Error(message)), ms)
49
- timer.unref?.()
50
- promise.then(
51
- (value) => { clearTimeout(timer); resolve(value) },
52
- (error) => { clearTimeout(timer); reject(error) },
53
- )
54
- })
55
- }
56
-
57
- /** Builds remote-workspace contexts off the shared ledger + engine. */
58
- export class RemoteWorkspaceRunner {
59
- constructor(
60
- private readonly engine: SshEngine,
61
- private readonly ledger: SshWorkspaceLedger,
62
- ) {}
63
-
64
- /** Resolve a LOCAL anchor path to a remote context, or null if not SSH-bound. */
65
- resolveRemote(root: string): RemoteWorkspaceContext | null {
66
- const record = this.ledger.findByAnchorSync(root)
67
- if (record === undefined) return null
68
- return this.build(record.alias, record.remoteRoot)
69
- }
70
-
71
- private build(alias: string, remoteRoot: string): RemoteWorkspaceContext {
72
- const engine = this.engine
73
- const git = async (
74
- args: readonly string[],
75
- opts: { timeoutMs?: number; input?: string; allowNonZero?: boolean } = {},
76
- ): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
77
- const quoted = args.map(arg => quote(arg)).join(' ')
78
- // Restore real stderr ordering by capturing both streams; the engine
79
- // exec returns them merged per frame — keep them merged for git too.
80
- const command = `git ${quoted}`
81
- const result = await engine.exec(alias, `cd ${quote(remoteRoot)} && ${command}`, opts.timeoutMs)
82
- const stdout = result.stdout
83
- const stderr = result.stderr
84
- const exitCode = result.exitCode ?? 1
85
- if (exitCode !== 0 && opts.allowNonZero !== true) {
86
- const err = new Error(stderr.trim() || `git exited ${exitCode}`)
87
- ;(err as { shortMessage?: string }).shortMessage = stderr.trim()
88
- throw err
89
- }
90
- return { stdout, stderr, exitCode }
91
- }
92
- const run = async (
93
- command: string,
94
- opts: { timeoutMs?: number } = {},
95
- ): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
96
- const result = await engine.exec(alias, `cd ${quote(remoteRoot)} && ${command}`, opts.timeoutMs)
97
- return {
98
- stdout: result.stdout,
99
- stderr: result.stderr,
100
- exitCode: result.exitCode ?? 1,
101
- }
102
- }
103
- const list = async (path: string): Promise<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>> => {
104
- return engine.ls(alias, path)
105
- }
106
- const stat = async (path: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> => {
107
- return engine.stat(alias, path)
108
- }
109
- const readFile = async (path: string): Promise<{ content: Buffer; mtime: number; size: number }> => {
110
- return engine.readFile(alias, path)
111
- }
112
- const writeFile = async (path: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }> => {
113
- return engine.writeFile(alias, path, content, expectedMtime)
114
- }
115
- const mkdir = async (path: string): Promise<void> => { await engine.mkdir(alias, path) }
116
- const rm = async (path: string, recursive: boolean): Promise<void> => { await engine.rm(alias, path, recursive) }
117
- const rename = async (from: string, to: string): Promise<void> => { await engine.rename(alias, from, to) }
118
- const openTerminal = async (cols: number, rows: number): Promise<RemotePtyHandle> => {
119
- // Bound the shell open: the engine's own connect/channel timeouts cover
120
- // the normal failure modes, this is a last-resort guard so a terminal
121
- // request can never hang the workbench's SSE stream indefinitely.
122
- const session = await withTimeout(
123
- engine.openShell(alias, { cols, rows }),
124
- OPEN_SHELL_TIMEOUT_MS,
125
- `SSH 终端打开超时(${OPEN_SHELL_TIMEOUT_MS / 1000} 秒):${alias}`,
126
- )
127
- let exited = false
128
- const exitHandlers = new Set<(event: { exitCode: number }) => void>()
129
- const onDataHandlers = new Set<(data: string) => void>()
130
- const enc = new TextDecoder()
131
-
132
- // The PTY already runs the user's interactive login shell (client.shell
133
- // allocates one); just cd into the remote root. Do NOT exec a new shell
134
- // here — that restarts the shell, re-runs profile/.bashrc, and replays
135
- // the login banner + prompt (the "command ran twice" symptom).
136
- //
137
- // Send `cd` only AFTER the first shell prompt has been emitted — a
138
- // prompt (line ending in $ / # / > / %) means the login banner finished
139
- // and the shell is interactive. Sending earlier makes the PTY echo the
140
- // command raw AND the shell re-echo it after the prompt (double cd).
141
- let cwdSent = false
142
- let shellBuf = ''
143
- const trySendCwd = (): void => {
144
- if (cwdSent || exited) return
145
- // Prompt heuristic: the last non-empty line ends with a prompt char
146
- // (`$ # > %`) OR looks like a user@host prompt (e.g. `@login01 ~`,
147
- // `majie@host:~/dir$`). Either means the shell is interactive now.
148
- const lines = shellBuf.split('\n').map(line => line.replace(/\r$/, '')).filter(line => line.trim() !== '')
149
- const last = lines[lines.length - 1]
150
- if (last !== undefined
151
- && ( /[#$>%]\s*$/.test(last) || /^\S+@\S+/.test(last) )
152
- && !last.trim().endsWith('cd')) {
153
- cwdSent = true
154
- session.send(`cd ${quote(remoteRoot)}\r`)
155
- }
156
- }
157
- session.onData = (data: Buffer) => {
158
- const text = enc.decode(data)
159
- shellBuf = (shellBuf + text).slice(-4096)
160
- trySendCwd()
161
- for (const handler of onDataHandlers) handler(text)
162
- }
163
- session.onExit = (code: number | null) => {
164
- exited = true
165
- for (const handler of exitHandlers) handler({ exitCode: code ?? -1 })
166
- }
167
- // Fallback: if no prompt heuristic matched (odd PS1), send cd after 4s.
168
- setTimeout(() => {
169
- if (!cwdSent && !exited) {
170
- cwdSent = true
171
- session.send(`cd ${quote(remoteRoot)}\r`)
172
- }
173
- }, 4000)
174
- return {
175
- shell: 'ssh',
176
- write(data: string) { if (!exited) session.send(data) },
177
- resize(c: number, r: number) { session.resize(c, r) },
178
- kill() {
179
- try { session.signal('KILL') } catch { /* closed */ }
180
- try { session.close() } catch { /* closed */ }
181
- },
182
- onData(handler: (data: string) => void) {
183
- onDataHandlers.add(handler)
184
- return { dispose: () => { onDataHandlers.delete(handler) } }
185
- },
186
- onExit(handler: (event: { exitCode: number }) => void) {
187
- exitHandlers.add(handler)
188
- return { dispose: () => { exitHandlers.delete(handler) } }
189
- },
190
- }
191
- }
192
- return { alias, remoteRoot, git, run, list, stat, readFile, writeFile, mkdir, rm, rename, openTerminal }
193
- }
194
- }
195
-
196
- /** POSIX single-quote a shell argument. */
197
- function quote(arg: string): string {
198
- if (arg === '') return "''"
199
- if (/^[a-zA-Z0-9_\-./:=@]+$/.test(arg)) return arg
200
- return `'${arg.replace(/'/g, `'\\''`)}'`
201
- }