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,123 @@
1
+ /**
2
+ * Services tab: list Windows services for a host, filter, and run
3
+ * start/stop/restart/startup-type actions.
4
+ */
5
+ import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import type { WinrmApi } from '../api.ts'
7
+ import type { ServiceInfo } from '../../protocol.ts'
8
+ import { errorMessage, tt } from './helpers.ts'
9
+ import css from './panel.module.css'
10
+
11
+ /** Services tab props. */
12
+ export interface ServicesTabProps {
13
+ api: WinrmApi
14
+ }
15
+
16
+ /** The services table with host picker and actions. */
17
+ export function ServicesTab({ api }: ServicesTabProps) {
18
+ const [aliases, setAliases] = useState<string[]>([])
19
+ const [alias, setAlias] = useState('')
20
+ const [services, setServices] = useState<ServiceInfo[] | null>(null)
21
+ const [filter, setFilter] = useState('')
22
+ const [error, setError] = useState<string | null>(null)
23
+ const [notice, setNotice] = useState<string | null>(null)
24
+ const [acting, setActing] = useState<string | null>(null)
25
+ const mountedRef = useRef(true)
26
+ useEffect(() => () => { mountedRef.current = false }, [])
27
+
28
+ useEffect(() => {
29
+ void api.listHosts().then(list => setAliases(list.map(h => h.alias))).catch(() => { /* ignore */ })
30
+ }, [api])
31
+
32
+ const load = useCallback(async (target: string): Promise<void> => {
33
+ try {
34
+ const list = await api.listServices(target)
35
+ if (!mountedRef.current) return
36
+ setServices(list)
37
+ setError(null)
38
+ } catch (cause) {
39
+ if (!mountedRef.current) return
40
+ setError(errorMessage(cause))
41
+ setServices(null)
42
+ }
43
+ }, [api])
44
+
45
+ useEffect(() => {
46
+ if (alias === '') return
47
+ setServices(null)
48
+ void load(alias)
49
+ }, [alias, load])
50
+
51
+ const act = async (name: string, action: string): Promise<void> => {
52
+ setActing(name)
53
+ setNotice(null)
54
+ setError(null)
55
+ try {
56
+ const updated = await api.serviceAction(alias, name, action)
57
+ setNotice(tt('services.actionOk', { name: updated.name, status: updated.status }))
58
+ void load(alias)
59
+ } catch (cause) {
60
+ setError(tt('services.actionFail', { error: errorMessage(cause) }))
61
+ } finally {
62
+ setActing(null)
63
+ }
64
+ }
65
+
66
+ const needle = filter.trim().toLowerCase()
67
+ const visible = services === null ? [] : services.filter(s =>
68
+ s.name.toLowerCase().includes(needle) || s.displayName.toLowerCase().includes(needle))
69
+
70
+ return (
71
+ <div className={css.fillBody}>
72
+ <div className={css.toolbar}>
73
+ <select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
74
+ <option value="">{tt('services.select')}</option>
75
+ {aliases.map(a => <option key={a} value={a}>{a}</option>)}
76
+ </select>
77
+ <input className={css.search} type="search" placeholder={tt('services.filter')} value={filter} onChange={event => { setFilter(event.target.value) }} />
78
+ <div className={css.toolbarSpacer} />
79
+ <button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias) }}>{tt('common.refresh')}</button>
80
+ </div>
81
+ {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
82
+ {error !== null && <div className={css.banner} data-kind="error">{error}</div>}
83
+ {services === null && alias !== '' && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
84
+ {services !== null && (
85
+ <div className={css.tableWrap}>
86
+ <table className={css.table}>
87
+ <thead>
88
+ <tr>
89
+ <th>{tt('services.name')}</th>
90
+ <th>{tt('services.displayName')}</th>
91
+ <th>{tt('services.status')}</th>
92
+ <th>{tt('services.startMode')}</th>
93
+ <th>{tt('services.startName')}</th>
94
+ <th>{tt('hosts.col.actions')}</th>
95
+ </tr>
96
+ </thead>
97
+ <tbody>
98
+ {visible.map(service => (
99
+ <tr key={service.name}>
100
+ <td className={css.mono}>{service.name}</td>
101
+ <td>{service.displayName}</td>
102
+ <td><span className={css.badge} data-kind={service.status === 'Running' ? 'ok' : 'muted'}>{service.status}</span></td>
103
+ <td>{service.startMode}</td>
104
+ <td className={css.cellMuted}>{service.startName}</td>
105
+ <td>
106
+ <div className={css.actions}>
107
+ <button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'start') }}>{tt('services.start')}</button>
108
+ <button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'stop') }}>{tt('services.stop')}</button>
109
+ <button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'restart') }}>{tt('services.restart')}</button>
110
+ <button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'set-auto') }}>{tt('services.auto')}</button>
111
+ <button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'set-manual') }}>{tt('services.manual')}</button>
112
+ <button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'set-disabled') }}>{tt('services.disabled')}</button>
113
+ </div>
114
+ </td>
115
+ </tr>
116
+ ))}
117
+ </tbody>
118
+ </table>
119
+ </div>
120
+ )}
121
+ </div>
122
+ )
123
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Transfer tab: browse remote directories (ls), download files to the local
3
+ * machine, and upload local files to the remote host. Transfers ride the
4
+ * NDJSON progress stream (upload) / content-length progress (download).
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import type { WinrmApi } from '../api.ts'
8
+ import type { RemoteDirEntry, TransferProgress } from '../../protocol.ts'
9
+ import { errorMessage, formatBytes, tt } from './helpers.ts'
10
+ import css from './panel.module.css'
11
+
12
+ /** Transfer tab props. */
13
+ export interface TransferTabProps {
14
+ api: WinrmApi
15
+ }
16
+
17
+ /** One remote directory entry with a clickable path. */
18
+ function joinPath(dir: string, name: string): string {
19
+ return dir.replace(/[\\/]+$/, '') + '\\' + name
20
+ }
21
+
22
+ /** The file browser with upload/download. */
23
+ export function TransferTab({ api }: TransferTabProps) {
24
+ const [aliases, setAliases] = useState<string[]>([])
25
+ const [alias, setAlias] = useState('')
26
+ const [dir, setDir] = useState('C:\\')
27
+ const [entries, setEntries] = useState<RemoteDirEntry[] | null>(null)
28
+ const [error, setError] = useState<string | null>(null)
29
+ const [notice, setNotice] = useState<string | null>(null)
30
+ const [progress, setProgress] = useState<TransferProgress | null>(null)
31
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
32
+ const mountedRef = useRef(true)
33
+ useEffect(() => () => { mountedRef.current = false }, [])
34
+
35
+ useEffect(() => {
36
+ void api.listHosts().then(list => setAliases(list.map(h => h.alias))).catch(() => { /* ignore */ })
37
+ }, [api])
38
+
39
+ const load = useCallback(async (target: string, path: string): Promise<void> => {
40
+ try {
41
+ const list = await api.ls(target, path)
42
+ if (!mountedRef.current) return
43
+ setEntries(list)
44
+ setDir(path)
45
+ setError(null)
46
+ } catch (cause) {
47
+ if (!mountedRef.current) return
48
+ setError(errorMessage(cause))
49
+ setEntries(null)
50
+ }
51
+ }, [api])
52
+
53
+ const open = (): void => {
54
+ if (alias === '' || dir === '') return
55
+ setEntries(null)
56
+ void load(alias, dir)
57
+ }
58
+
59
+ const pickAlias = (value: string): void => {
60
+ setAlias(value)
61
+ setEntries(null)
62
+ if (value !== '') void load(value, 'C:\\')
63
+ }
64
+
65
+ const download = async (entry: RemoteDirEntry): Promise<void> => {
66
+ if (entry.type !== 'file') return
67
+ setProgress({ phase: 'transferring', file: entry.name, transferred: 0, total: 0, percent: 0 })
68
+ setError(null)
69
+ try {
70
+ const outcome = await api.downloadFile(alias, joinPath(dir, entry.name), p => setProgress(p))
71
+ setNotice(tt('transfer.downloaded', { bytes: outcome.bytes }))
72
+ } catch (cause) {
73
+ setError(tt('transfer.fail', { error: errorMessage(cause) }))
74
+ } finally {
75
+ setProgress(null)
76
+ }
77
+ }
78
+
79
+ const upload = async (file: File): Promise<void> => {
80
+ if (file === undefined) return
81
+ const remotePath = joinPath(dir, file.name)
82
+ setProgress({ phase: 'transferring', file: file.name, transferred: 0, total: file.size, percent: 0 })
83
+ setError(null)
84
+ try {
85
+ const outcome = await api.uploadFile(file, alias, remotePath, p => setProgress(p))
86
+ setNotice(tt('transfer.uploaded', { bytes: outcome.transferredBytes }))
87
+ void load(alias, dir)
88
+ } catch (cause) {
89
+ setError(tt('transfer.fail', { error: errorMessage(cause) }))
90
+ } finally {
91
+ setProgress(null)
92
+ }
93
+ }
94
+
95
+ return (
96
+ <div className={css.fillBody}>
97
+ <div className={css.toolbar}>
98
+ <select className={css.input} value={alias} onChange={event => { pickAlias(event.target.value) }}>
99
+ <option value="">{tt('transfer.select')}</option>
100
+ {aliases.map(a => <option key={a} value={a}>{a}</option>)}
101
+ </select>
102
+ <input className={css.search} value={dir} onChange={event => { setDir(event.target.value) }} onKeyDown={event => { if (event.key === 'Enter') open() }} />
103
+ <button type="button" className={css.primaryButton} disabled={alias === '' || dir === ''} onClick={open}>{tt('transfer.go')}</button>
104
+ <button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias, dir.replace(/[\\/][^\\/]*$/, '') || 'C:\\') }}>{tt('transfer.up')}</button>
105
+ <button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias, 'C:\\') }}>{tt('transfer.root')}</button>
106
+ <div className={css.toolbarSpacer} />
107
+ <button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { fileInputRef.current?.click() }}>{tt('transfer.upload')}</button>
108
+ <input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={event => {
109
+ const file = event.target.files?.[0]
110
+ if (file !== undefined) void upload(file)
111
+ event.target.value = ''
112
+ }} />
113
+ </div>
114
+ {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
115
+ {error !== null && <div className={css.banner} data-kind="error">{error}</div>}
116
+ {progress !== null && (
117
+ <div className={css.banner} data-kind="ok">
118
+ {progress.phase === 'transferring' ? (progress.total > 0 ? tt('transfer.downloading') : tt('transfer.uploading')) : tt('transfer.uploading')}
119
+ {' '}{formatBytes(progress.transferred)}{progress.total > 0 ? ' / ' + formatBytes(progress.total) + ' (' + progress.percent + '%)' : ''}
120
+ </div>
121
+ )}
122
+ {entries === null && alias !== '' && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
123
+ {entries !== null && (
124
+ <div className={css.tableWrap}>
125
+ <table className={css.table}>
126
+ <thead>
127
+ <tr>
128
+ <th>{tt('transfer.name')}</th>
129
+ <th>{tt('transfer.type')}</th>
130
+ <th>{tt('transfer.size')}</th>
131
+ <th>{tt('transfer.modified')}</th>
132
+ <th>{tt('hosts.col.actions')}</th>
133
+ </tr>
134
+ </thead>
135
+ <tbody>
136
+ {entries.map(entry => (
137
+ <tr key={entry.name}>
138
+ <td>
139
+ {entry.type === 'dir' ? (
140
+ <button type="button" className={css.linkButton} onClick={() => { void load(alias, joinPath(dir, entry.name)) }}>📁 {entry.name}</button>
141
+ ) : (
142
+ <span className={css.mono}>{entry.name}</span>
143
+ )}
144
+ </td>
145
+ <td>{entry.type === 'dir' ? tt('transfer.dir') : tt('transfer.file')}</td>
146
+ <td>{entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
147
+ <td className={css.cellMuted}>{entry.mtimeMs > 0 ? new Date(entry.mtimeMs).toLocaleString() : ''}</td>
148
+ <td>
149
+ {entry.type === 'file' && (
150
+ <button type="button" className={css.linkButton} onClick={() => { void download(entry) }}>{tt('transfer.download')}</button>
151
+ )}
152
+ </td>
153
+ </tr>
154
+ ))}
155
+ </tbody>
156
+ </table>
157
+ </div>
158
+ )}
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The WinRM operations panel shell: a header with a back control, a
3
+ * five-tab bar, and the active tab's content. Tab state lives here (reset to
4
+ * Hosts when the panel is reopened, since the keyed main slot unmounts the
5
+ * occupant on close); inactive tabs unmount, so each tab fetches its own data
6
+ * on activation. The hosts tab's connect action switches here to the console
7
+ * tab with the chosen alias preselected.
8
+ */
9
+ import { useState } from 'react'
10
+ import type { WinrmApi } from '../api.ts'
11
+ import { tt } from './helpers.ts'
12
+ import { ConsoleTab } from './ConsoleTab.tsx'
13
+ import { HostsTab } from './HostsTab.tsx'
14
+ import { ProcessesTab } from './ProcessesTab.tsx'
15
+ import { ServicesTab } from './ServicesTab.tsx'
16
+ import { TransferTab } from './TransferTab.tsx'
17
+ import css from './panel.module.css'
18
+
19
+ /** The panel's tab identifiers. */
20
+ export type WinrmTab = 'hosts' | 'console' | 'services' | 'processes' | 'transfer'
21
+
22
+ /** Panel shell props (the inject face supplied by the main-slot registration). */
23
+ export interface WinrmPanelProps {
24
+ /** The WinRM API client every tab operates through. */
25
+ api: WinrmApi
26
+ /** Return to the Conversation (wired to ctx.layout.selectPanel(null)). */
27
+ onBack: () => void
28
+ }
29
+
30
+ /** The tab bar definition (labels resolved at render time). */
31
+ const TABS: ReadonlyArray<{ id: WinrmTab; label: () => string }> = [
32
+ { id: 'hosts', label: () => tt('tab.hosts') },
33
+ { id: 'console', label: () => tt('tab.console') },
34
+ { id: 'services', label: () => tt('tab.services') },
35
+ { id: 'processes', label: () => tt('tab.processes') },
36
+ { id: 'transfer', label: () => tt('tab.transfer') },
37
+ ]
38
+
39
+ /** A pending "connect this host" request handed to the console tab. */
40
+ interface ConnectRequest {
41
+ alias: string
42
+ nonce: number
43
+ }
44
+
45
+ /** The tabbed WinRM panel. */
46
+ export function WinrmPanel({ api, onBack }: WinrmPanelProps) {
47
+ const [activeTab, setActiveTab] = useState<WinrmTab>('hosts')
48
+ const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
49
+
50
+ const handleConnect = (alias: string): void => {
51
+ setActiveTab('console')
52
+ setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
53
+ }
54
+
55
+ return (
56
+ <div className={css.panel}>
57
+ <div className={css.panelHeader}>
58
+ <button
59
+ type="button"
60
+ className={`${css.ghostButton} ${css.backButton}`}
61
+ aria-label={tt('panel.backToConversation')}
62
+ onClick={onBack}
63
+ >
64
+ <span aria-hidden="true">‹</span>
65
+ <span>{tt('panel.backToConversation')}</span>
66
+ </button>
67
+ <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
68
+ </div>
69
+ <div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
70
+ {TABS.map(tab => (
71
+ <button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
72
+ {tab.label()}
73
+ </button>
74
+ ))}
75
+ </div>
76
+ <div className={css.panelContent}>
77
+ {activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
78
+ {activeTab === 'console' && <ConsoleTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
79
+ {activeTab === 'services' && <ServicesTab api={api} />}
80
+ {activeTab === 'processes' && <ProcessesTab api={api} />}
81
+ {activeTab === 'transfer' && <TransferTab api={api} />}
82
+ </div>
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Sidebar `panellist` icon for the WinRM panel. The shell owns the row
3
+ * button and its label; this component only renders the glyph at the size
4
+ * and active state the row requests.
5
+ */
6
+ import type { SidebarPanelIconOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client'
7
+
8
+ /** One glyph contribution for the `sidebar.panellist` list slot. */
9
+ export function WinrmPanelIcon({ size }: SidebarPanelIconOwnerProps) {
10
+ return (
11
+ <svg
12
+ viewBox="0 0 16 16"
13
+ width={size}
14
+ height={size}
15
+ fill="none"
16
+ stroke="currentColor"
17
+ strokeWidth={1.3}
18
+ strokeLinecap="round"
19
+ strokeLinejoin="round"
20
+ aria-hidden="true"
21
+ >
22
+ <rect x="2" y="2.5" width="12" height="11" rx="1.5" />
23
+ <path d="M2.5 6.5h11" />
24
+ <path d="M6 6.5v7" />
25
+ </svg>
26
+ )
27
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared panel helpers: the active-dictionary pick (document-language based)
3
+ * bound to the dsh-winrm interpolator in locales.ts, plus a small
4
+ * error-message extractor.
5
+ */
6
+ import { en, t, zh, type WinKey } from '../locales.ts'
7
+
8
+ /** Template values accepted by the interpolator. */
9
+ export type TranslateValues = Record<string, string | number>
10
+
11
+ /** Active dictionary, picked by the document language at call time. */
12
+ export function dictionary(): Record<string, string> {
13
+ const lang = typeof document !== 'undefined' ? document.documentElement.lang : 'zh'
14
+ return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
15
+ }
16
+
17
+ /** Translate a key with optional {name} template params (current language). */
18
+ export function tt(key: WinKey, values?: TranslateValues): string {
19
+ return t(dictionary(), key, values)
20
+ }
21
+
22
+ /** Human-readable error text from an unknown thrown value. */
23
+ export function errorMessage(error: unknown): string {
24
+ if (error instanceof Error) return error.message
25
+ return String(error)
26
+ }
27
+
28
+ /** Format a byte count into B/KB/MB. */
29
+ export function formatBytes(bytes: number): string {
30
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
31
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
32
+ return bytes + ' B'
33
+ }