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.
- package/LICENSE +73 -0
- package/README.md +112 -0
- package/README.zh.md +119 -0
- package/RELEASING.md +75 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2076 -0
- package/lib/index.js +2458 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +59 -0
- package/lib/types/client/index.d.ts +38 -0
- package/lib/types/client/locales.d.ts +231 -0
- package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
- package/lib/types/client/panel/ServicesTab.d.ts +7 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
- package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
- package/lib/types/client/panel/helpers.d.ts +16 -0
- package/lib/types/engine/client.d.ts +49 -0
- package/lib/types/engine/console.d.ts +23 -0
- package/lib/types/engine.d.ts +58 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/powershell.d.ts +73 -0
- package/lib/types/protocol.d.ts +176 -0
- package/lib/types/routes.d.ts +26 -0
- package/lib/types/smb.d.ts +31 -0
- package/lib/types/store.d.ts +44 -0
- package/lib/types/tools.d.ts +20 -0
- package/package.json +111 -0
- package/scripts/enable-winrm.ps1 +0 -0
- package/scripts/postbuild.mjs +40 -0
- package/scripts/validate-manifest.mjs +18 -0
- package/src/client/api.ts +345 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +82 -0
- package/src/client/locales.ts +243 -0
- package/src/client/panel/ConsoleTab.tsx +142 -0
- package/src/client/panel/HostFormDialog.tsx +142 -0
- package/src/client/panel/HostsTab.tsx +153 -0
- package/src/client/panel/ProcessesTab.tsx +119 -0
- package/src/client/panel/ServicesTab.tsx +123 -0
- package/src/client/panel/TransferTab.tsx +161 -0
- package/src/client/panel/WinrmPanel.tsx +85 -0
- package/src/client/panel/WinrmPanelIcon.tsx +27 -0
- package/src/client/panel/helpers.ts +33 -0
- package/src/client/panel/panel.module.css +1000 -0
- package/src/engine/client.ts +291 -0
- package/src/engine/console.ts +62 -0
- package/src/engine.ts +331 -0
- package/src/index.ts +142 -0
- package/src/invariant.ts +4 -0
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/powershell.ts +208 -0
- package/src/protocol.ts +178 -0
- package/src/routes.ts +490 -0
- package/src/smb.ts +129 -0
- package/src/store.ts +232 -0
- 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,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
|
+
}
|