dsh-winrm 0.1.0

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 (64) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +112 -0
  3. package/README.zh.md +119 -0
  4. package/RELEASING.md +75 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +2076 -0
  7. package/lib/index.js +2458 -0
  8. package/lib/invariant.js +6 -0
  9. package/lib/types/client/api.d.ts +59 -0
  10. package/lib/types/client/index.d.ts +38 -0
  11. package/lib/types/client/locales.d.ts +231 -0
  12. package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
  13. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  14. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  15. package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
  16. package/lib/types/client/panel/ServicesTab.d.ts +7 -0
  17. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  18. package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
  19. package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
  20. package/lib/types/client/panel/helpers.d.ts +16 -0
  21. package/lib/types/engine/client.d.ts +49 -0
  22. package/lib/types/engine/console.d.ts +23 -0
  23. package/lib/types/engine.d.ts +58 -0
  24. package/lib/types/index.d.ts +44 -0
  25. package/lib/types/invariant.d.ts +3 -0
  26. package/lib/types/loopback.d.ts +24 -0
  27. package/lib/types/mount-once.d.ts +24 -0
  28. package/lib/types/powershell.d.ts +73 -0
  29. package/lib/types/protocol.d.ts +176 -0
  30. package/lib/types/routes.d.ts +26 -0
  31. package/lib/types/smb.d.ts +31 -0
  32. package/lib/types/store.d.ts +44 -0
  33. package/lib/types/tools.d.ts +20 -0
  34. package/package.json +111 -0
  35. package/scripts/enable-winrm.ps1 +0 -0
  36. package/scripts/postbuild.mjs +40 -0
  37. package/scripts/validate-manifest.mjs +18 -0
  38. package/src/client/api.ts +345 -0
  39. package/src/client/css-modules.d.ts +5 -0
  40. package/src/client/index.ts +82 -0
  41. package/src/client/locales.ts +243 -0
  42. package/src/client/panel/ConsoleTab.tsx +142 -0
  43. package/src/client/panel/HostFormDialog.tsx +142 -0
  44. package/src/client/panel/HostsTab.tsx +153 -0
  45. package/src/client/panel/ProcessesTab.tsx +119 -0
  46. package/src/client/panel/ServicesTab.tsx +123 -0
  47. package/src/client/panel/TransferTab.tsx +161 -0
  48. package/src/client/panel/WinrmPanel.tsx +85 -0
  49. package/src/client/panel/WinrmPanelIcon.tsx +27 -0
  50. package/src/client/panel/helpers.ts +33 -0
  51. package/src/client/panel/panel.module.css +1000 -0
  52. package/src/engine/client.ts +291 -0
  53. package/src/engine/console.ts +62 -0
  54. package/src/engine.ts +331 -0
  55. package/src/index.ts +142 -0
  56. package/src/invariant.ts +4 -0
  57. package/src/loopback.ts +63 -0
  58. package/src/mount-once.ts +48 -0
  59. package/src/powershell.ts +208 -0
  60. package/src/protocol.ts +178 -0
  61. package/src/routes.ts +490 -0
  62. package/src/smb.ts +129 -0
  63. package/src/store.ts +232 -0
  64. package/src/tools.ts +387 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Browser-side API client for the /api/dsh-winrm route family. The only data
3
+ * access path the panel components use — plain fetch/WebSocket, same origin.
4
+ */
5
+
6
+ import {
7
+ WINRM_API,
8
+ type ClusterResult,
9
+ type ConsoleClientFrame,
10
+ type ConsoleServerFrame,
11
+ type ExecResult,
12
+ type ProcessInfo,
13
+ type RemoteDirEntry,
14
+ type ServiceInfo,
15
+ type TestResult,
16
+ type TransferProgress,
17
+ type TransferStreamLine,
18
+ type WinHostPayload,
19
+ type WinHostSummary,
20
+ } from '../protocol.ts'
21
+
22
+ /** Minimal File System Access API surface (not in all lib.dom versions). */
23
+ interface WindowWithFileSystemAccess {
24
+ showSaveFilePicker?: (options: { suggestedName?: string }) => Promise<{
25
+ createWritable: () => Promise<{ write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> }>
26
+ }>
27
+ }
28
+
29
+ /** Error carrying the route's JSON error message. */
30
+ export class WinrmApiError extends Error {
31
+ constructor(message: string) {
32
+ super(message)
33
+ this.name = 'WinrmApiError'
34
+ }
35
+ }
36
+
37
+ /** Parse a JSON response or throw a WinrmApiError. */
38
+ async function readJson<T>(response: Response): Promise<T> {
39
+ let body: unknown
40
+ try {
41
+ body = await response.json()
42
+ } catch {
43
+ throw new WinrmApiError(`HTTP ${response.status}: invalid JSON response`)
44
+ }
45
+ if (!response.ok) {
46
+ const message = typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
47
+ ? (body as { error: string }).error
48
+ : `HTTP ${response.status}`
49
+ throw new WinrmApiError(message)
50
+ }
51
+ return body as T
52
+ }
53
+
54
+ /** Query-string helper. */
55
+ function query(params: Record<string, string | number | undefined>): string {
56
+ const search = new URLSearchParams()
57
+ for (const [key, value] of Object.entries(params)) {
58
+ if (value !== undefined && value !== '') search.set(key, String(value))
59
+ }
60
+ const text = search.toString()
61
+ return text === '' ? '' : '?' + text
62
+ }
63
+
64
+ /** One open console connection (WebSocket JSON frames). */
65
+ export interface ConsoleConnection {
66
+ onReady: (() => void) | undefined
67
+ onOutput: ((data: string) => void) | undefined
68
+ onExit: ((code: number | null, error?: string) => void) | undefined
69
+ send(data: string): void
70
+ close(): void
71
+ }
72
+
73
+ /** The browser half's only data entry point. */
74
+ export class WinrmApi {
75
+ // -------------------------------------------------------------- hosts
76
+ async listHosts(queryText?: string): Promise<WinHostSummary[]> {
77
+ const response = await fetch(WINRM_API.hosts + query({ query: queryText }))
78
+ const body = await readJson<{ hosts: WinHostSummary[] }>(response)
79
+ return body.hosts
80
+ }
81
+
82
+ async createHost(payload: WinHostPayload): Promise<WinHostSummary> {
83
+ const response = await fetch(WINRM_API.hosts, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify(payload),
87
+ })
88
+ const body = await readJson<{ host: WinHostSummary }>(response)
89
+ return body.host
90
+ }
91
+
92
+ async updateHost(alias: string, patch: WinHostPayload): Promise<WinHostSummary> {
93
+ const response = await fetch(WINRM_API.hosts + query({ alias }), {
94
+ method: 'PATCH',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: JSON.stringify(patch),
97
+ })
98
+ const body = await readJson<{ host: WinHostSummary }>(response)
99
+ return body.host
100
+ }
101
+
102
+ async deleteHost(alias: string): Promise<void> {
103
+ const response = await fetch(WINRM_API.hosts + query({ alias }), { method: 'DELETE' })
104
+ await readJson<{ ok: boolean }>(response)
105
+ }
106
+
107
+ // ---------------------------------------------------------------- ops
108
+ async testHost(alias: string): Promise<TestResult> {
109
+ const response = await fetch(WINRM_API.test, {
110
+ method: 'POST',
111
+ headers: { 'content-type': 'application/json' },
112
+ body: JSON.stringify({ alias }),
113
+ })
114
+ const body = await readJson<{ result: TestResult }>(response)
115
+ return body.result
116
+ }
117
+
118
+ async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
119
+ const response = await fetch(WINRM_API.exec, {
120
+ method: 'POST',
121
+ headers: { 'content-type': 'application/json' },
122
+ body: JSON.stringify({ alias, command, timeoutMs }),
123
+ })
124
+ const body = await readJson<{ result: ExecResult }>(response)
125
+ return body.result
126
+ }
127
+
128
+ async cluster(options: {
129
+ command: string
130
+ aliases?: string[]
131
+ environment?: string
132
+ tags?: string[]
133
+ timeoutMs?: number
134
+ maxWorkers?: number
135
+ }): Promise<ClusterResult[]> {
136
+ const response = await fetch(WINRM_API.cluster, {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify(options),
140
+ })
141
+ const body = await readJson<{ results: ClusterResult[] }>(response)
142
+ return body.results
143
+ }
144
+
145
+ // ------------------------------------------------------------ services
146
+ async listServices(alias: string): Promise<ServiceInfo[]> {
147
+ const response = await fetch(WINRM_API.services, {
148
+ method: 'POST',
149
+ headers: { 'content-type': 'application/json' },
150
+ body: JSON.stringify({ alias, action: 'list' }),
151
+ })
152
+ const body = await readJson<{ services: ServiceInfo[] }>(response)
153
+ return body.services
154
+ }
155
+
156
+ async serviceAction(alias: string, name: string, action: string): Promise<ServiceInfo> {
157
+ const response = await fetch(WINRM_API.services, {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify({ alias, name, action }),
161
+ })
162
+ const body = await readJson<{ service: ServiceInfo }>(response)
163
+ return body.service
164
+ }
165
+
166
+ // ----------------------------------------------------------- processes
167
+ async listProcesses(alias: string): Promise<ProcessInfo[]> {
168
+ const response = await fetch(WINRM_API.processes, {
169
+ method: 'POST',
170
+ headers: { 'content-type': 'application/json' },
171
+ body: JSON.stringify({ alias, action: 'list' }),
172
+ })
173
+ const body = await readJson<{ processes: ProcessInfo[] }>(response)
174
+ return body.processes
175
+ }
176
+
177
+ async killProcess(alias: string, id: number): Promise<void> {
178
+ const response = await fetch(WINRM_API.processes, {
179
+ method: 'POST',
180
+ headers: { 'content-type': 'application/json' },
181
+ body: JSON.stringify({ alias, action: 'kill', id }),
182
+ })
183
+ await readJson<{ killed: number }>(response)
184
+ }
185
+
186
+ // ----------------------------------------------------------------- ls
187
+ async ls(alias: string, dir: string): Promise<RemoteDirEntry[]> {
188
+ const response = await fetch(WINRM_API.ls + query({ alias, path: dir }))
189
+ const body = await readJson<{ entries: RemoteDirEntry[] }>(response)
190
+ return body.entries
191
+ }
192
+
193
+ // ------------------------------------------------------------ transfer
194
+ /**
195
+ * Upload one file (raw bytes) to a remote path. Progress arrives through
196
+ * the NDJSON response stream; resolves when the result frame lands.
197
+ */
198
+ async uploadFile(
199
+ file: File,
200
+ alias: string,
201
+ remotePath: string,
202
+ onProgress?: (progress: TransferProgress) => void,
203
+ ): Promise<{ transferredBytes: number }> {
204
+ const response = await fetch(WINRM_API.upload + query({ alias, remotePath }), {
205
+ method: 'POST',
206
+ body: file,
207
+ })
208
+ if (!response.ok || response.body === null) {
209
+ throw new WinrmApiError(`upload failed: HTTP ${response.status}`)
210
+ }
211
+ const reader = response.body.getReader()
212
+ const decoder = new TextDecoder()
213
+ let buffer = ''
214
+ let finalError: string | undefined
215
+ let sawResult = false
216
+ let transferredBytes = 0
217
+ for (;;) {
218
+ const { done, value } = await reader.read()
219
+ if (done) break
220
+ buffer += decoder.decode(value, { stream: true })
221
+ const lines = buffer.split('\n')
222
+ buffer = lines.pop() ?? ''
223
+ for (const line of lines) {
224
+ if (line.trim() === '') continue
225
+ let parsed: TransferStreamLine
226
+ try {
227
+ parsed = JSON.parse(line) as TransferStreamLine
228
+ } catch {
229
+ continue
230
+ }
231
+ if (parsed.type === 'progress') {
232
+ onProgress?.(parsed.progress)
233
+ } else if (parsed.type === 'result') {
234
+ sawResult = true
235
+ if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
236
+ finalError = parsed.ok ? undefined : parsed.error ?? 'upload failed'
237
+ }
238
+ }
239
+ }
240
+ if (finalError !== undefined) throw new WinrmApiError(finalError)
241
+ if (!sawResult) throw new WinrmApiError('upload ended without a result frame — the transfer did not complete')
242
+ return { transferredBytes }
243
+ }
244
+
245
+ /**
246
+ * Download a remote file with client-side progress. Streams straight to
247
+ * disk when the File System Access API is available; otherwise falls back
248
+ * to an in-memory Blob.
249
+ */
250
+ async downloadFile(
251
+ alias: string,
252
+ remotePath: string,
253
+ onProgress?: (progress: TransferProgress) => void,
254
+ ): Promise<{ blob?: Blob; filename: string; streamed: boolean; bytes: number }> {
255
+ const response = await fetch(WINRM_API.download + query({ alias, remotePath }))
256
+ if (!response.ok || response.body === null) {
257
+ const text = await response.text().catch(() => '')
258
+ throw new WinrmApiError(text !== '' && text.startsWith('{') ? text : `download failed: HTTP ${response.status}`)
259
+ }
260
+ const total = Number(response.headers.get('content-length') ?? '0')
261
+ const disposition = response.headers.get('content-disposition') ?? ''
262
+ const match = /filename="([^"]+)"/.exec(disposition)
263
+ const filename = match?.[1] ?? remotePath.split(/[\\/]/).pop() ?? 'download'
264
+ const reader = response.body.getReader()
265
+ const picker = typeof window !== 'undefined'
266
+ ? (window as WindowWithFileSystemAccess).showSaveFilePicker
267
+ : undefined
268
+ let streamed = false
269
+ let writable: { write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> } | undefined
270
+ const chunks: Uint8Array<ArrayBuffer>[] = []
271
+ let received = 0
272
+ const progress = (): void => {
273
+ onProgress?.({
274
+ phase: 'transferring',
275
+ file: remotePath,
276
+ transferred: received,
277
+ total,
278
+ percent: total > 0 ? Math.round((received / total) * 1000) / 10 : 0,
279
+ })
280
+ }
281
+ try {
282
+ if (picker !== undefined) {
283
+ const handle = await picker.call(window, { suggestedName: filename })
284
+ writable = await handle.createWritable()
285
+ streamed = true
286
+ }
287
+ } catch {
288
+ // User cancelled the save dialog or the API is unavailable: fall back.
289
+ }
290
+ for (;;) {
291
+ const { done, value } = await reader.read()
292
+ if (done) break
293
+ if (writable !== undefined) {
294
+ await writable.write(value as Uint8Array)
295
+ } else {
296
+ chunks.push(value as Uint8Array<ArrayBuffer>)
297
+ }
298
+ received += value.length
299
+ progress()
300
+ }
301
+ if (writable !== undefined) await writable.close()
302
+ onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
303
+ return {
304
+ blob: streamed ? undefined : new Blob(chunks),
305
+ filename,
306
+ streamed,
307
+ bytes: received,
308
+ }
309
+ }
310
+
311
+ // ------------------------------------------------------------ console
312
+ /** Open a WebSocket PowerShell console session. */
313
+ openConsole(alias: string): ConsoleConnection {
314
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
315
+ const url = scheme + '://' + window.location.host + WINRM_API.console + query({ alias })
316
+ const socket = new WebSocket(url)
317
+ const connection: ConsoleConnection = {
318
+ onReady: undefined,
319
+ onOutput: undefined,
320
+ onExit: undefined,
321
+ send: (data) => {
322
+ if (socket.readyState === WebSocket.OPEN) {
323
+ socket.send(JSON.stringify({ type: 'input', data } satisfies ConsoleClientFrame))
324
+ }
325
+ },
326
+ close: () => {
327
+ try { socket.close() } catch { /* already closed */ }
328
+ },
329
+ }
330
+ socket.onmessage = (event: MessageEvent<string>) => {
331
+ let frame: ConsoleServerFrame
332
+ try {
333
+ frame = JSON.parse(event.data) as ConsoleServerFrame
334
+ } catch {
335
+ return
336
+ }
337
+ if (frame.type === 'ready') connection.onReady?.()
338
+ else if (frame.type === 'output') connection.onOutput?.(frame.data)
339
+ else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
340
+ }
341
+ socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
342
+ socket.onerror = () => { connection.onExit?.(null, 'connection error') }
343
+ return connection
344
+ }
345
+ }
@@ -0,0 +1,5 @@
1
+ /** CSS module imports: hashed class map (bundled by the tsdown css plugin). */
2
+ declare module '*.module.css' {
3
+ const classes: Record<string, string>
4
+ export default classes
5
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Browser-half entry for the dsh-winrm plugin — runs inside the dsh web GUI.
3
+ * Registers locale dictionaries and contributes the "Windows" sidebar entry
4
+ * plus the central operations panel through the shell's slot system: a
5
+ * `sidebar.panellist` list row (icon + label) that selects the keyed `main`
6
+ * panel, and the `main` occupant that renders the tabbed WinRM panel in the
7
+ * center column. This replaces the earlier DOM-injection approach with the
8
+ * first-class extension points the shell now provides. DOM/slot mounting
9
+ * problems are logged, never thrown — an external plugin must not take the
10
+ * GUI down.
11
+ *
12
+ * Export discipline (packages/client rule): the /client surface carries what
13
+ * cordis loading needs plus types only — all value exports stay internal.
14
+ */
15
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
16
+ // Type-only: the locale plugin's Context merge (ctx.locale).
17
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
18
+ // Type-only: the SlotRegistry service merge (ctx.slots).
19
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
20
+ // Type-only: the keyed `main` slot, the MainPanelId brand, and the ctx.layout merge.
21
+ import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
22
+ // Type-only: the `sidebar.panellist` list slot declaration.
23
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
24
+ import { WinrmApi } from './api.ts'
25
+ import { en, zh, type WinKey } from './locales.ts'
26
+ import { WinrmPanel } from './panel/WinrmPanel.tsx'
27
+ import { WinrmPanelIcon } from './panel/WinrmPanelIcon.tsx'
28
+
29
+ /** Locale namespace this plugin owns. */
30
+ const NS = 'dsh-winrm'
31
+
32
+ /** The id shared by the sidebar entry and the main panel it opens. */
33
+ const PANEL_ID = 'winrm' as MainPanelId
34
+
35
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
36
+ interface LocaleNamespaceMap {
37
+ /** dsh-winrm surface copy. */
38
+ 'dsh-winrm': WinKey
39
+ }
40
+ }
41
+
42
+ /** Required services (fiber inject waiting — slots, locale, and layout must be up first). */
43
+ export const inject = ['slots', 'locale', 'layout']
44
+
45
+ /** Type-only surface (export discipline: no value exports beyond the plugin contract). */
46
+ export type { WinrmPanelProps } from './panel/WinrmPanel.tsx'
47
+ export type { HostsTabProps } from './panel/HostsTab.tsx'
48
+ export type { HostFormDialogProps } from './panel/HostFormDialog.tsx'
49
+ export type { ConsoleTabProps } from './panel/ConsoleTab.tsx'
50
+ export type { ServicesTabProps } from './panel/ServicesTab.tsx'
51
+ export type { ProcessesTabProps } from './panel/ProcessesTab.tsx'
52
+ export type { TransferTabProps } from './panel/TransferTab.tsx'
53
+ export type { WinKey } from './locales.ts'
54
+
55
+ /**
56
+ * Contribute the Windows sidebar entry and the tabbed WinRM panel it opens.
57
+ * @param ctx - client root context (locale, slots, layout services).
58
+ */
59
+ export function apply(ctx: ClientContext): void {
60
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-winrm: dictionaries')
61
+ const t = ctx.locale.bind(NS)
62
+ const api = new WinrmApi()
63
+
64
+ // The panel is a global main-panel occupant: the sidebar entry selects it,
65
+ // and the shell swaps the center column between the Conversation and this
66
+ // keyed occupant. `onBack` returns to the Conversation.
67
+ ctx.slots.inject('main', () => ctx.slots.register({
68
+ name: 'main',
69
+ key: PANEL_ID,
70
+ locale: NS,
71
+ inject: () => ({ api, onBack: () => { ctx.layout.selectPanel(null) } }),
72
+ }, WinrmPanel))
73
+
74
+ // The sidebar row: an icon plus a localized label; selecting it opens the panel.
75
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
76
+ name: 'sidebar.panellist',
77
+ id: PANEL_ID,
78
+ order: 10,
79
+ label: () => t('entry.label'),
80
+ locale: NS,
81
+ }, WinrmPanelIcon))
82
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Locale dictionaries for the WinRM panel (zh / en), bound to the shared
3
+ * interpolator in panel/helpers.ts.
4
+ */
5
+
6
+ export const zh = {
7
+ 'entry.label': 'Windows',
8
+ 'entry.tooltip': 'Windows 远程管理(WinRM)',
9
+ 'panel.title': 'Windows 远程管理',
10
+ 'panel.backToConversation': '返回对话',
11
+ 'tab.hosts': '主机',
12
+ 'tab.console': '控制台',
13
+ 'tab.services': '服务',
14
+ 'tab.processes': '进程',
15
+ 'tab.transfer': '传输',
16
+ 'common.loading': '加载中…',
17
+ 'common.error': '错误:{error}',
18
+ 'common.refresh': '刷新',
19
+ 'hosts.search': '搜索主机…',
20
+ 'hosts.add': '添加主机',
21
+ 'hosts.test': '测试',
22
+ 'hosts.testing': '测试中…',
23
+ 'hosts.edit': '编辑',
24
+ 'hosts.delete': '删除',
25
+ 'hosts.deleteConfirm': '确认删除主机 {alias}?',
26
+ 'hosts.connect': '连接控制台',
27
+ 'hosts.testOk': '连接成功({latency} ms)',
28
+ 'hosts.testFail': '连接失败:{error}',
29
+ 'hosts.empty': '尚未配置 Windows 主机,点「添加主机」开始。',
30
+ 'hosts.col.alias': '别名',
31
+ 'hosts.col.host': '主机',
32
+ 'hosts.col.user': '用户',
33
+ 'hosts.col.auth': '认证',
34
+ 'hosts.col.transport': '传输',
35
+ 'hosts.col.environment': '环境',
36
+ 'hosts.col.tags': '标签',
37
+ 'hosts.col.description': '备注',
38
+ 'hosts.col.actions': '操作',
39
+ 'form.title.create': '添加 Windows 主机',
40
+ 'form.title.edit': '编辑主机 {alias}',
41
+ 'form.alias': '别名',
42
+ 'form.aliasHint': '英文字母/数字/点/连字符/下划线',
43
+ 'form.host': '主机名或 IP',
44
+ 'form.port': 'WinRM 端口',
45
+ 'form.user': '用户名',
46
+ 'form.userHint': '由原生 Node WinRM 客户端自动选择 NTLM/Negotiate 或 Basic;支持本地账户、DOMAIN\\user 或 user@domain',
47
+ 'form.password': '密码',
48
+ 'form.passwordHint': '仅保存在本机 ~/.dsh/dsh-winrm.json(0600),不显示在页面上',
49
+ 'form.transport': '传输',
50
+ 'form.transport.http': 'HTTP (5985)',
51
+ 'form.transport.https': 'HTTPS (5986)',
52
+ 'form.rejectUnauthorized': '接受自签名证书(HTTPS)',
53
+ 'form.environment': '环境(development / production…)',
54
+ 'form.tags': '标签(逗号分隔)',
55
+ 'form.description': '备注',
56
+ 'form.save': '保存',
57
+ 'form.cancel': '取消',
58
+ 'form.error': '错误:{error}',
59
+ 'form.passwordPlaceholder': '(保持不变)',
60
+ 'console.select': '选择主机开始控制台',
61
+ 'console.connecting': '正在连接 {alias}…',
62
+ 'console.connected': '已连接 {alias} — 输入 PowerShell 命令,回车执行;输入 exit 退出',
63
+ 'console.disconnected': '连接已关闭',
64
+ 'console.disconnect': '断开',
65
+ 'console.inputPlaceholder': 'PowerShell 命令…',
66
+ 'console.send': '发送',
67
+ 'console.utf8Note': '注意:控制台经 WinRM 传输,中文输出可能乱码;需要精确中文输出请用「exec」或文件传输',
68
+ 'services.select': '选择主机查看服务',
69
+ 'services.list': '服务列表',
70
+ 'services.name': '服务名',
71
+ 'services.displayName': '显示名',
72
+ 'services.status': '状态',
73
+ 'services.startMode': '启动类型',
74
+ 'services.startName': '运行账户',
75
+ 'services.start': '启动',
76
+ 'services.stop': '停止',
77
+ 'services.restart': '重启',
78
+ 'services.auto': '自动',
79
+ 'services.manual': '手动',
80
+ 'services.disabled': '禁用',
81
+ 'services.actionOk': '操作成功:{name} → {status}',
82
+ 'services.actionFail': '操作失败:{error}',
83
+ 'services.filter': '筛选…',
84
+ 'processes.select': '选择主机查看进程',
85
+ 'processes.id': 'PID',
86
+ 'processes.name': '名称',
87
+ 'processes.cpu': 'CPU',
88
+ 'processes.memMB': '内存(MB)',
89
+ 'processes.startTime': '启动时间',
90
+ 'processes.path': '路径',
91
+ 'processes.kill': '结束',
92
+ 'processes.killConfirm': '确认结束进程 {name} (PID {id})?',
93
+ 'processes.killed': '已结束进程 PID {id}',
94
+ 'processes.killFail': '结束失败:{error}',
95
+ 'processes.filter': '筛选…',
96
+ 'transfer.select': '选择主机浏览文件',
97
+ 'transfer.path': '路径',
98
+ 'transfer.go': '打开',
99
+ 'transfer.up': '上级',
100
+ 'transfer.root': '根目录',
101
+ 'transfer.name': '名称',
102
+ 'transfer.type': '类型',
103
+ 'transfer.size': '大小',
104
+ 'transfer.modified': '修改时间',
105
+ 'transfer.download': '下载',
106
+ 'transfer.upload': '上传文件',
107
+ 'transfer.uploading': '上传中…',
108
+ 'transfer.downloading': '下载中…',
109
+ 'transfer.uploaded': '已上传 {bytes} 字节',
110
+ 'transfer.downloaded': '已下载 {bytes} 字节',
111
+ 'transfer.fail': '传输失败:{error}',
112
+ 'transfer.bytes': 'B',
113
+ 'transfer.kib': 'KB',
114
+ 'transfer.mib': 'MB',
115
+ 'transfer.dir': '目录',
116
+ 'transfer.file': '文件',
117
+ } as const
118
+
119
+ export const en = {
120
+ 'entry.label': 'Windows',
121
+ 'entry.tooltip': 'Windows remote administration (WinRM)',
122
+ 'panel.title': 'Windows Remote',
123
+ 'panel.backToConversation': 'Back to chat',
124
+ 'tab.hosts': 'Hosts',
125
+ 'tab.console': 'Console',
126
+ 'tab.services': 'Services',
127
+ 'tab.processes': 'Processes',
128
+ 'tab.transfer': 'Transfer',
129
+ 'common.loading': 'Loading…',
130
+ 'common.error': 'Error: {error}',
131
+ 'common.refresh': 'Refresh',
132
+ 'hosts.search': 'Search hosts…',
133
+ 'hosts.add': 'Add host',
134
+ 'hosts.test': 'Test',
135
+ 'hosts.testing': 'Testing…',
136
+ 'hosts.edit': 'Edit',
137
+ 'hosts.delete': 'Delete',
138
+ 'hosts.deleteConfirm': 'Delete host {alias}?',
139
+ 'hosts.connect': 'Console',
140
+ 'hosts.testOk': 'connected ({latency} ms)',
141
+ 'hosts.testFail': 'failed: {error}',
142
+ 'hosts.empty': 'No Windows hosts configured yet — click "Add host" to start.',
143
+ 'hosts.col.alias': 'Alias',
144
+ 'hosts.col.host': 'Host',
145
+ 'hosts.col.user': 'User',
146
+ 'hosts.col.auth': 'Auth',
147
+ 'hosts.col.transport': 'Transport',
148
+ 'hosts.col.environment': 'Environment',
149
+ 'hosts.col.tags': 'Tags',
150
+ 'hosts.col.description': 'Description',
151
+ 'hosts.col.actions': 'Actions',
152
+ 'form.title.create': 'Add Windows host',
153
+ 'form.title.edit': 'Edit host {alias}',
154
+ 'form.alias': 'Alias',
155
+ 'form.aliasHint': 'letters, digits, dots, hyphens or underscores',
156
+ 'form.host': 'Hostname or IP',
157
+ 'form.port': 'WinRM port',
158
+ 'form.user': 'Username',
159
+ 'form.userHint': 'Native Node WinRM client auto-selects NTLM/Negotiate or Basic; supports local, DOMAIN\\user, or user@domain accounts',
160
+ 'form.password': 'Password',
161
+ 'form.passwordHint': 'Stored locally only in ~/.dsh/dsh-winrm.json (0600), never shown here',
162
+ 'form.transport': 'Transport',
163
+ 'form.transport.http': 'HTTP (5985)',
164
+ 'form.transport.https': 'HTTPS (5986)',
165
+ 'form.rejectUnauthorized': 'Accept self-signed certificates (HTTPS)',
166
+ 'form.environment': 'Environment (development / production…)',
167
+ 'form.tags': 'Tags (comma separated)',
168
+ 'form.description': 'Description',
169
+ 'form.save': 'Save',
170
+ 'form.cancel': 'Cancel',
171
+ 'form.error': 'Error: {error}',
172
+ 'form.passwordPlaceholder': '(unchanged)',
173
+ 'console.select': 'Pick a host to open the console',
174
+ 'console.connecting': 'Connecting to {alias}…',
175
+ 'console.connected': 'Connected to {alias} — type a PowerShell command and press Enter; type exit to quit',
176
+ 'console.disconnected': 'Connection closed',
177
+ 'console.disconnect': 'Disconnect',
178
+ 'console.inputPlaceholder': 'PowerShell command…',
179
+ 'console.send': 'Send',
180
+ 'console.utf8Note': 'Note: the console travels over WinRM and non-ASCII output may garble; use exec or file transfer for exact UTF-8 content',
181
+ 'services.select': 'Pick a host to view services',
182
+ 'services.list': 'Services',
183
+ 'services.name': 'Name',
184
+ 'services.displayName': 'Display name',
185
+ 'services.status': 'Status',
186
+ 'services.startMode': 'Start mode',
187
+ 'services.startName': 'Run as',
188
+ 'services.start': 'Start',
189
+ 'services.stop': 'Stop',
190
+ 'services.restart': 'Restart',
191
+ 'services.auto': 'Auto',
192
+ 'services.manual': 'Manual',
193
+ 'services.disabled': 'Disabled',
194
+ 'services.actionOk': 'OK: {name} → {status}',
195
+ 'services.actionFail': 'Failed: {error}',
196
+ 'services.filter': 'Filter…',
197
+ 'processes.select': 'Pick a host to view processes',
198
+ 'processes.id': 'PID',
199
+ 'processes.name': 'Name',
200
+ 'processes.cpu': 'CPU',
201
+ 'processes.memMB': 'Mem(MB)',
202
+ 'processes.startTime': 'Started',
203
+ 'processes.path': 'Path',
204
+ 'processes.kill': 'Kill',
205
+ 'processes.killConfirm': 'Kill process {name} (PID {id})?',
206
+ 'processes.killed': 'Killed PID {id}',
207
+ 'processes.killFail': 'Failed: {error}',
208
+ 'processes.filter': 'Filter…',
209
+ 'transfer.select': 'Pick a host to browse files',
210
+ 'transfer.path': 'Path',
211
+ 'transfer.go': 'Open',
212
+ 'transfer.up': 'Up',
213
+ 'transfer.root': 'Root',
214
+ 'transfer.name': 'Name',
215
+ 'transfer.type': 'Type',
216
+ 'transfer.size': 'Size',
217
+ 'transfer.modified': 'Modified',
218
+ 'transfer.download': 'Download',
219
+ 'transfer.upload': 'Upload file',
220
+ 'transfer.uploading': 'Uploading…',
221
+ 'transfer.downloading': 'Downloading…',
222
+ 'transfer.uploaded': 'Uploaded {bytes} bytes',
223
+ 'transfer.downloaded': 'Downloaded {bytes} bytes',
224
+ 'transfer.fail': 'Transfer failed: {error}',
225
+ 'transfer.bytes': 'B',
226
+ 'transfer.kib': 'KB',
227
+ 'transfer.mib': 'MB',
228
+ 'transfer.dir': 'dir',
229
+ 'transfer.file': 'file',
230
+ } as const
231
+
232
+ export type WinKey = keyof typeof zh
233
+
234
+ /** Translate a key in one dictionary with optional {name} params. */
235
+ export function t(dict: Record<string, string>, key: WinKey, values?: Record<string, string | number>): string {
236
+ let text = dict[key] ?? key
237
+ if (values !== undefined) {
238
+ for (const [name, value] of Object.entries(values)) {
239
+ text = text.split('{' + name + '}').join(String(value))
240
+ }
241
+ }
242
+ return text
243
+ }