@linxin666/dsh-ssh 0.2.0 → 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.
@@ -24,3 +24,47 @@ export function errorMessage(error: unknown): string {
24
24
  if (error instanceof Error) return error.message
25
25
  return String(error)
26
26
  }
27
+
28
+ /**
29
+ * The terminal font stack used before issue #577, kept as the last resort
30
+ * when neither the plugin setting nor a CSS custom property names a font.
31
+ */
32
+ export const TERMINAL_FONT_FALLBACK = 'Menlo, Consolas, "Liberation Mono", monospace'
33
+
34
+ /**
35
+ * Live source of the user's configured terminal font family: the client
36
+ * entry binds the dsh-ssh settings namespace into one of these and hands it
37
+ * to the panel, so a settings change re-applies without a remount.
38
+ */
39
+ export interface TerminalFontSource {
40
+ /** Current configured family, or undefined when unset / not yet loaded. */
41
+ get(): string | undefined
42
+ /** Subscribe to changes; the returned function unsubscribes. */
43
+ subscribe(listener: () => void): () => void
44
+ }
45
+
46
+ /**
47
+ * Resolve the xterm `fontFamily` (issue #577). xterm's DOM renderer takes
48
+ * the font only from constructor/options, so a plain stylesheet rule cannot
49
+ * retarget it — the value must be read back into the options. The chain,
50
+ * first non-empty value wins:
51
+ * 1. `override` — the `terminalFontFamily` plugin setting;
52
+ * 2. `--dsh-ssh-terminal-font` — dedicated hook for skins and user CSS
53
+ * (e.g. a Nerd Font for powerline glyphs);
54
+ * 3. `--ds-font-family-code` — the official code-font token several skins
55
+ * already remap;
56
+ * 4. {@link TERMINAL_FONT_FALLBACK}.
57
+ */
58
+ export function resolveTerminalFontFamily(override?: string): string {
59
+ const trimmed = override?.trim()
60
+ if (trimmed !== undefined && trimmed !== '') return trimmed
61
+ if (typeof getComputedStyle === 'function' && typeof document !== 'undefined') {
62
+ const target = document.body ?? document.documentElement
63
+ const style = getComputedStyle(target)
64
+ for (const name of ['--dsh-ssh-terminal-font', '--ds-font-family-code']) {
65
+ const value = style.getPropertyValue(name).trim()
66
+ if (value !== '') return value
67
+ }
68
+ }
69
+ return TERMINAL_FONT_FALLBACK
70
+ }
@@ -0,0 +1,208 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/sidebar-entry-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared sidebar entry injection core.
4
+ *
5
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
6
+ * so the entry row is injected between the shell's New Session button and the
7
+ * workspace browser. The injection self-heals: a MutationObserver watches the
8
+ * sidebar 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 view it toggles is a separate root owned by the caller.
13
+ *
14
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
+ * edit the shared source and re-run the sync instead of editing a copy.
16
+ */
17
+
18
+ /** Per-package configuration for one sidebar entry row. */
19
+ export interface SidebarEntryOptions {
20
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
21
+ rowAttribute: string
22
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
23
+ rowSelector: string
24
+ /**
25
+ * L2 semantic-attribute plugin id (issue #506, enum table:
26
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
27
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
28
+ * leaves the row without semantic attributes.
29
+ */
30
+ plugin?: string
31
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
32
+ icon: string
33
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
34
+ css: Record<string, string>
35
+ /** Localized row label (aria-label + visible text). */
36
+ label(): string
37
+ /** Optional localized tooltip (title attribute). */
38
+ tooltip?(): string
39
+ /** Click action (open/toggle the owning panel). */
40
+ onToggle(): void
41
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
42
+ position: 'before' | 'after'
43
+ /**
44
+ * Selectors of the sibling plugin entry rows this package orders against
45
+ * (its own row included — the placement guard excludes a row that is
46
+ * already inside the root). Each package passes the same list it used
47
+ * before the consolidation so the rendered order stays stable.
48
+ */
49
+ familySelectors: readonly string[]
50
+ /** Optional active-state bridge; highlights the row while the panel is open. */
51
+ active?: {
52
+ subscribe(listener: () => void): () => void
53
+ isOpen(): boolean
54
+ }
55
+ }
56
+
57
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
58
+ function sidebarRoot(): HTMLElement | undefined {
59
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
60
+ if (column === null) return undefined
61
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
62
+ // Prefer the element that owns the logo row — the real sidebar UI root —
63
+ // and fall back to the column's first child for legacy shells.
64
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
65
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
66
+ }
67
+
68
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
69
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
70
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
71
+ if (nested !== null) return nested
72
+ for (const child of root.children) {
73
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ /** Build the entry row (a detached button; insert once the shell is up). */
79
+ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
80
+ const entry = document.createElement('button')
81
+ entry.type = 'button'
82
+ entry.setAttribute(options.rowAttribute, '')
83
+ if (options.plugin !== undefined) {
84
+ entry.setAttribute('data-dsh-plugin', options.plugin)
85
+ entry.setAttribute('data-dsh-part', 'sidebar-entry')
86
+ }
87
+ entry.className = options.css['entry'] ?? ''
88
+ entry.setAttribute('aria-label', options.label())
89
+ if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
90
+ entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
91
+ + '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
92
+ entry.addEventListener('click', options.onToggle)
93
+ return entry
94
+ }
95
+
96
+ /** Re-insert the entry after the New Session row (before the browser region). */
97
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
98
+ const button = newSessionButton(root)
99
+ if (button === undefined) return false
100
+ if (entry.parentElement !== root) {
101
+ // Position relative to the family block (entries injected by sibling
102
+ // plugins), never relative to transient logoRow geometry: every family
103
+ // plugin that self-heals during a re-render then lands in the same
104
+ // relative order, so the entries cannot swap positions regardless of
105
+ // observer callback order or of shell wrapper changes. There is no
106
+ // append-to-end fallback: appending at the end would randomly reorder
107
+ // the block after a shell re-render.
108
+ const row = button.closest('[class*="logoRow"]')
109
+ const base = (row !== null && row.parentElement === root) ? row : button
110
+ const family = Array.from(root.children).filter(
111
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
112
+ )
113
+ const anchor = options.position === 'before'
114
+ ? (family.length > 0 ? family[0] : base.nextElementSibling)
115
+ : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
116
+ root.insertBefore(entry, anchor)
117
+ }
118
+ return true
119
+ }
120
+
121
+ /**
122
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
123
+ * on later React re-renders.
124
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
125
+ * @returns disposer removing the entry and its observers.
126
+ */
127
+ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
128
+ // DOM-level idempotency: whatever path mounted an entry row before this
129
+ // call (a duplicated apply, an HMR re-injection, a stale module still
130
+ // alive), never mount a second one. The existing row keeps working; a full
131
+ // page reload is the ultimate reset.
132
+ if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
133
+ return () => {}
134
+ }
135
+ const entry = createEntry(options)
136
+ let root: HTMLElement | undefined
137
+ let placed = false
138
+
139
+ const tryPlace = (): void => {
140
+ if (root !== undefined && !root.isConnected) {
141
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
142
+ // observer is gone with the old tree, so detach it and re-query from
143
+ // scratch. The new pane is later noticed by the body-level watcher.
144
+ rootObserver.disconnect()
145
+ root = undefined
146
+ placed = false
147
+ }
148
+ if (placed) {
149
+ // Cheap short-circuit: entry still lives in a mountable subtree.
150
+ if (document.body.contains(entry)) return
151
+ // Entry was torn down together with the old tree; reset and re-place.
152
+ rootObserver.disconnect()
153
+ root = undefined
154
+ placed = false
155
+ }
156
+ root ??= sidebarRoot()
157
+ if (root === undefined) return
158
+ placed = placeEntry(root, entry, options)
159
+ if (placed) {
160
+ rootObserver.observe(root, { childList: true, subtree: true })
161
+ }
162
+ }
163
+
164
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
165
+ // tears down the whole sidebar pane, the root observer is gone with it and
166
+ // only this body observation can notice the new pane mounting. It is no
167
+ // longer disconnected after placement; the placed-and-still-mounted case
168
+ // short-circuits through the cheap document.body.contains(entry) check, so
169
+ // unrelated app mutations (e.g. chat streaming) cost one contains check
170
+ // instead of churning the full re-query.
171
+ const waitObserver = new MutationObserver(() => { tryPlace() })
172
+ waitObserver.observe(document.body, { childList: true, subtree: true })
173
+
174
+ // Self-heal: if a React re-render displaces the row, re-insert it in the
175
+ // same frame (microtask before paint -> no visible flicker).
176
+ const rootObserver = new MutationObserver(() => {
177
+ if (root === undefined || !root.isConnected) {
178
+ placed = false
179
+ tryPlace()
180
+ return
181
+ }
182
+ if (!root.contains(entry)) {
183
+ placed = placeEntry(root, entry, options)
184
+ }
185
+ })
186
+
187
+ // Reflect the panel's open state on the row (active highlight). Note: assigning
188
+ // undefined to dataset.active materializes data-active="undefined" and keeps the
189
+ // row permanently highlighted — delete the attribute instead.
190
+ const unsubscribeActive = options.active === undefined ? undefined : (() => {
191
+ const syncActive = (): void => {
192
+ if (options.active!.isOpen()) entry.dataset.active = 'true'
193
+ else delete entry.dataset.active
194
+ }
195
+ const unsubscribe = options.active.subscribe(syncActive)
196
+ syncActive()
197
+ return unsubscribe
198
+ })()
199
+
200
+ tryPlace()
201
+
202
+ return () => {
203
+ waitObserver.disconnect()
204
+ rootObserver.disconnect()
205
+ unsubscribeActive?.()
206
+ entry.remove()
207
+ }
208
+ }
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
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).
4
+ * The DOM injection / self-healing / idempotency logic lives exactly once in
5
+ * shared/client/sidebar-entry-core.ts (synced copy); this wrapper supplies the
6
+ * ssh icon, copy, CSS module, and the panel toggle. The row is plain DOM (no
7
+ * React tree) so it can never disturb the shell's reconciliation; the panel
8
+ * view it toggles is a separate React root mounted in the center column
9
+ * (see mount.tsx).
14
10
  */
15
11
  import type { PanelController } from './panel/controller.ts'
16
12
  import { tt } from './panel/helpers.ts'
17
13
  import css from './panel/panel.module.css'
14
+ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
18
15
 
19
16
  /** Stable data attribute identifying the injected entry row. */
20
17
  export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
@@ -22,64 +19,6 @@ export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
22
19
  /** Inline icon (matches the shell's 16px nav-icon look): a terminal prompt glyph. */
23
20
  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>'
24
21
 
25
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
26
- function sidebarRoot(): HTMLElement | undefined {
27
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
- if (column === null) return undefined
29
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
30
- // Prefer the element that owns the logo row — the real sidebar UI root —
31
- // and fall back to the column's first child for legacy shells.
32
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
33
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
34
- }
35
-
36
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
37
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
38
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
39
- if (nested !== null) return nested
40
- for (const child of root.children) {
41
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
42
- }
43
- return undefined
44
- }
45
-
46
- /** Build the entry row (a detached button; insert once the shell is up). */
47
- function createEntry(controller: PanelController): HTMLButtonElement {
48
- const entry = document.createElement('button')
49
- entry.type = 'button'
50
- entry.dataset.dshSshEntry = ''
51
- entry.className = css.entry
52
- entry.setAttribute('aria-label', tt('entry.label'))
53
- entry.setAttribute('title', tt('entry.tooltip'))
54
- entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + tt('entry.label') + '</span>'
55
- entry.addEventListener('click', () => { controller.toggle() })
56
- return entry
57
- }
58
-
59
- /** Re-insert the entry after the New Session row (before the browser region). */
60
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
- const button = newSessionButton(root)
62
- if (button === undefined) return false
63
- if (entry.parentElement !== root) {
64
- // Position relative to the family block (entries injected by sibling
65
- // plugins), never relative to transient logoRow geometry: every family
66
- // plugin that self-heals during a re-render then lands in the same
67
- // relative order, so the entries cannot swap positions regardless of
68
- // observer callback order or of shell wrapper changes. There is no
69
- // append-to-end fallback: appending at the end would randomly reorder
70
- // the block after a shell re-render.
71
- const row = button.closest('[class*="logoRow"]')
72
- const base = (row !== null && row.parentElement === root) ? row : button
73
- const family = Array.from(root.children).filter(
74
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
- )
76
- // ssh sits after the whole family block.
77
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
78
- root.insertBefore(entry, anchor)
79
- }
80
- return true
81
- }
82
-
83
22
  /**
84
23
  * Mount the sidebar entry, waiting for the shell to render and self-healing
85
24
  * on later React re-renders.
@@ -87,74 +26,20 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
87
26
  * @returns disposer removing the entry and its observers.
88
27
  */
89
28
  export function mountSidebarEntry(controller: PanelController): () => void {
90
- const entry = createEntry(controller)
91
- let root: HTMLElement | undefined
92
- let placed = false
93
-
94
- const tryPlace = (): void => {
95
- if (root !== undefined && !root.isConnected) {
96
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
97
- // observer is gone with the old tree, so detach it and re-query from
98
- // scratch. The new pane is later noticed by the body-level watcher.
99
- rootObserver.disconnect()
100
- root = undefined
101
- placed = false
102
- }
103
- if (placed) {
104
- // Cheap short-circuit: entry still lives in a mountable subtree.
105
- if (document.body.contains(entry)) return
106
- // Entry was torn down together with the old tree; reset and re-place.
107
- rootObserver.disconnect()
108
- root = undefined
109
- placed = false
110
- }
111
- root ??= sidebarRoot()
112
- if (root === undefined) return
113
- placed = placeEntry(root, entry)
114
- if (placed) {
115
- rootObserver.observe(root, { childList: true, subtree: true })
116
- }
117
- }
118
-
119
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
120
- // tears down the whole sidebar pane, the root observer is gone with it and
121
- // only this body observation can notice the new pane mounting. It is no
122
- // longer disconnected after placement; the placed-and-still-mounted case
123
- // short-circuits through the cheap document.body.contains(entry) check, so
124
- // unrelated app mutations (e.g. chat streaming) cost one contains check
125
- // instead of churning the full re-query.
126
- const waitObserver = new MutationObserver(() => { tryPlace() })
127
- waitObserver.observe(document.body, { childList: true, subtree: true })
128
-
129
- // Self-heal: if a React re-render displaces the row, re-insert it in the
130
- // same frame (microtask before paint -> no visible flicker).
131
- const rootObserver = new MutationObserver(() => {
132
- if (root === undefined || !root.isConnected) {
133
- placed = false
134
- tryPlace()
135
- return
136
- }
137
- if (!root.contains(entry)) {
138
- placed = placeEntry(root, entry)
139
- }
29
+ return mountSharedSidebarEntry({
30
+ rowAttribute: 'data-dsh-ssh-entry',
31
+ rowSelector: ENTRY_SELECTOR,
32
+ plugin: 'ssh',
33
+ icon: ICON,
34
+ css,
35
+ label: () => tt('entry.label'),
36
+ tooltip: () => tt('entry.tooltip'),
37
+ onToggle: () => { controller.toggle() },
38
+ position: 'after',
39
+ familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
40
+ active: {
41
+ subscribe: (listener) => controller.subscribe(listener),
42
+ isOpen: () => controller.getSnapshot().panelOpen,
43
+ },
140
44
  })
141
-
142
- // Reflect the panel's open state on the row (active highlight). Note: assigning
143
- // undefined to dataset.active materializes data-active="undefined" and keeps the
144
- // row permanently highlighted — delete the attribute instead.
145
- const syncActive = () => {
146
- if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
147
- else delete entry.dataset.active
148
- }
149
- const unsubscribe = controller.subscribe(syncActive)
150
- syncActive()
151
-
152
- tryPlace()
153
-
154
- return () => {
155
- waitObserver.disconnect()
156
- rootObserver.disconnect()
157
- unsubscribe()
158
- entry.remove()
159
- }
160
45
  }
@@ -140,28 +140,34 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
140
140
  const hops: Client[] = []
141
141
  let sock: ConnectConfig['sock']
142
142
  const chain = entry.proxyJump
143
- for (let index = 0; index < chain.length; index += 1) {
144
- const hopAlias = chain[index]
145
- const hop = engine.store.find(hopAlias)
146
- if (hop === undefined) {
147
- for (const client of hops) client.end()
148
- throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
- }
150
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
151
- hops.push(hopClient)
152
- const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
- const nextHost = next !== undefined ? next.host : entry.host
154
- const nextPort = next !== undefined ? next.port : entry.port
155
- sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
156
- hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
157
- if (error !== undefined) {
158
- for (const client of hops) client.end()
159
- reject(error)
160
- } else {
161
- resolve(stream)
162
- }
143
+ try {
144
+ for (let index = 0; index < chain.length; index += 1) {
145
+ const hopAlias = chain[index]
146
+ const hop = engine.store.find(hopAlias)
147
+ if (hop === undefined) {
148
+ throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
+ }
150
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
151
+ hops.push(hopClient)
152
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
+ const nextHost = next !== undefined ? next.host : entry.host
154
+ const nextPort = next !== undefined ? next.port : entry.port
155
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
156
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
157
+ if (error !== undefined) {
158
+ reject(error)
159
+ } else {
160
+ resolve(stream)
161
+ }
162
+ })
163
163
  })
164
- })
164
+ }
165
+ } catch (error) {
166
+ // A missing alias, a failed hop connect, or a failed forwardOut must all
167
+ // close the hops already connected, so a failed ProxyJump never leaks a
168
+ // middle-hop connection.
169
+ for (const client of hops) client.end()
170
+ throw error
165
171
  }
166
172
  let target: Client | undefined
167
173
  try {
package/src/engine/pty.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * output delivery.
5
5
  */
6
6
 
7
- import { Client } from 'ssh2'
8
7
  import { connectChain, type PoolEngine } from './connection-pool.ts'
9
8
 
10
9
  /** A live PTY shell session. */
package/src/index.ts CHANGED
@@ -42,11 +42,19 @@ export interface Config {
42
42
  announceToAgent?: boolean
43
43
  /** Master switch for the plugin (routes, tools, prompt section). */
44
44
  enabled?: boolean
45
+ /**
46
+ * xterm `fontFamily` for the web terminal (issue #577). Empty (default)
47
+ * defers to the CSS chain: `--dsh-ssh-terminal-font`, then the official
48
+ * `--ds-font-family-code` token, then the built-in monospace stack. Set a
49
+ * Nerd Font stack here to render powerline/Nerd glyphs.
50
+ */
51
+ terminalFontFamily?: string
45
52
  }
46
53
 
47
54
  export const Config: z<Config> = z.object({
48
55
  announceToAgent: z.boolean().default(true),
49
56
  enabled: z.boolean().default(true),
57
+ terminalFontFamily: z.string().default(''),
50
58
  })
51
59
 
52
60
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
package/src/routes.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * deployments must not serve them.
8
8
  */
9
9
 
10
- import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
10
+ import { closeSync, createReadStream, createWriteStream, mkdirSync, openSync } from 'node:fs'
11
11
  import { unlink } from 'node:fs/promises'
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
13
  import { basename, join } from 'node:path'
@@ -93,7 +93,9 @@ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade:
93
93
  const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
94
94
  // The upload route stages request bodies here; it must exist before the
95
95
  // first request (a missing dir would hang the first upload forever).
96
- mkdirSync(staging, { recursive: true })
96
+ // 0700 keeps in-flight transfers unreadable to other local users (the
97
+ // staged files below are 0600; downloads are pre-created 0600 as well).
98
+ mkdirSync(staging, { recursive: true, mode: 0o700 })
97
99
 
98
100
  /** Guard helper: fence + method check. */
99
101
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
@@ -353,7 +355,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
353
355
  }
354
356
  // Stage the uploaded bytes, then SFTP them out with progress frames.
355
357
  const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
356
- const sink = createWriteStream(tmp)
358
+ const sink = createWriteStream(tmp, { mode: 0o600 })
357
359
  let settled = false
358
360
  // Every terminal path (sink error, client abort, response loss) must
359
361
  // emit a result frame, end the response, and remove the tmp file.
@@ -437,6 +439,9 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
437
439
  }
438
440
  const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
439
441
  try {
442
+ // Pre-create with 0600: ssh2's fastGet opens the destination with
443
+ // umask-default permissions and a pre-created file keeps the mode.
444
+ closeSync(openSync(tmp, 'w', 0o600))
440
445
  const outcome = await engine.download(alias, remotePath, tmp)
441
446
  res.writeHead(200, {
442
447
  'content-type': 'application/octet-stream',