dsh-remote 0.8.3 → 0.8.5
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/README.md +220 -220
- package/README.zh.md +178 -178
- package/cordis.patch.yml +24 -24
- package/lib/client.js +244 -128
- package/lib/credential.js +107 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +116 -1
- package/lib/paths.js +169 -169
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +66 -66
package/lib/ignore.js
CHANGED
|
@@ -1,123 +1,123 @@
|
|
|
1
|
-
// dsh-remote — gitignore-style ignore matcher for mirror sync / remote search.
|
|
2
|
-
import { readFileSync } from 'node:fs'
|
|
3
|
-
//
|
|
4
|
-
// Deliberately a SUBSET of gitignore semantics (documented, no surprises):
|
|
5
|
-
// - blank lines and `#` comments are skipped
|
|
6
|
-
// - `!` negation is NOT supported (later rules never un-ignore)
|
|
7
|
-
// - a trailing `/` matches directories only (and everything under them)
|
|
8
|
-
// - a pattern containing `/` (or starting with `/`) is anchored to the
|
|
9
|
-
// workspace root; otherwise it matches the basename at ANY depth
|
|
10
|
-
// - `*` matches any run of chars except `/`; `**` matches any run incl. `/`;
|
|
11
|
-
// `?` matches one char except `/`
|
|
12
|
-
// - character classes (`[abc]`) are not supported (treated literally)
|
|
13
|
-
|
|
14
|
-
/** Convert one glob segment to a regex source (no `/` crossing). A leading
|
|
15
|
-
* double-star-slash (`**` + `/`) matches zero or more directories (gitignore). */
|
|
16
|
-
function globToRe(glob) {
|
|
17
|
-
let out = ''
|
|
18
|
-
let i = 0
|
|
19
|
-
const s = String(glob)
|
|
20
|
-
if (s.startsWith('**/')) { out = '(?:.*/)?'; i = 3 }
|
|
21
|
-
while (i < s.length) {
|
|
22
|
-
const c = s[i]
|
|
23
|
-
if (c === '*') {
|
|
24
|
-
if (s[i + 1] === '*') { out += '.*'; i += 2; continue }
|
|
25
|
-
out += '[^/]*'; i++; continue
|
|
26
|
-
}
|
|
27
|
-
if (c === '?') { out += '[^/]'; i++; continue }
|
|
28
|
-
if ('\\^$+{}[]()|.'.includes(c)) out += '\\' + c
|
|
29
|
-
else out += c
|
|
30
|
-
i++
|
|
31
|
-
}
|
|
32
|
-
return out
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Compile a list of ignore patterns into a matcher.
|
|
37
|
-
* @param {string[]|string} patterns lines or an array of lines
|
|
38
|
-
* @returns {(relPath: string, isDir?: boolean) => boolean} true = ignored.
|
|
39
|
-
* `relPath` is POSIX-style ('/' separated, no leading '/').
|
|
40
|
-
*/
|
|
41
|
-
export function compileIgnore(patterns) {
|
|
42
|
-
const rules = []
|
|
43
|
-
const lines = Array.isArray(patterns) ? patterns : String(patterns || '').split('\n')
|
|
44
|
-
for (let raw of lines) {
|
|
45
|
-
let line = String(raw || '').trim()
|
|
46
|
-
if (!line || line.startsWith('#')) continue
|
|
47
|
-
let dirOnly = false
|
|
48
|
-
if (line.endsWith('/')) { dirOnly = true; line = line.slice(0, -1) }
|
|
49
|
-
let anchored = line.startsWith('/')
|
|
50
|
-
if (anchored) line = line.slice(1)
|
|
51
|
-
if (!line) continue
|
|
52
|
-
const hasSlash = line.includes('/')
|
|
53
|
-
const isAnchored = anchored || hasSlash
|
|
54
|
-
const segRe = globToRe(line)
|
|
55
|
-
let re
|
|
56
|
-
if (isAnchored) {
|
|
57
|
-
// Root-anchored: match the full path from the root.
|
|
58
|
-
re = dirOnly
|
|
59
|
-
? new RegExp('^' + segRe + '(/.*)?$')
|
|
60
|
-
: new RegExp('^' + segRe + '$')
|
|
61
|
-
} else {
|
|
62
|
-
// Basename at any depth; a dir pattern also covers everything under it.
|
|
63
|
-
re = dirOnly
|
|
64
|
-
? new RegExp('(^|/)' + segRe + '(/|$)')
|
|
65
|
-
: new RegExp('(^|/)' + segRe + '$')
|
|
66
|
-
}
|
|
67
|
-
rules.push({ re, dirOnly })
|
|
68
|
-
}
|
|
69
|
-
return function isIgnored(relPath, isDir) {
|
|
70
|
-
const rel = String(relPath || '').replace(/^\/+/, '').replace(/\\/g, '/')
|
|
71
|
-
if (!rel) return false
|
|
72
|
-
for (const r of rules) {
|
|
73
|
-
if (r.re.test(rel)) return true
|
|
74
|
-
}
|
|
75
|
-
return false
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Built-in defaults: never pull/push these into/from a mirror. */
|
|
80
|
-
export const DEFAULT_IGNORE = [
|
|
81
|
-
'.git/',
|
|
82
|
-
'.svn/',
|
|
83
|
-
'.hg/',
|
|
84
|
-
'node_modules/',
|
|
85
|
-
'target/',
|
|
86
|
-
'dist/',
|
|
87
|
-
'build/',
|
|
88
|
-
'.venv/',
|
|
89
|
-
'venv/',
|
|
90
|
-
'__pycache__/',
|
|
91
|
-
'.idea/',
|
|
92
|
-
'.vscode/',
|
|
93
|
-
'*.o',
|
|
94
|
-
'*.a',
|
|
95
|
-
'*.so',
|
|
96
|
-
'*.dylib',
|
|
97
|
-
'*.exe',
|
|
98
|
-
'*.dll',
|
|
99
|
-
'*.class',
|
|
100
|
-
'*.jar',
|
|
101
|
-
'*.log',
|
|
102
|
-
'*.tmp',
|
|
103
|
-
'.DS_Store',
|
|
104
|
-
'Thumbs.db',
|
|
105
|
-
'.dsh-remote-meta.json',
|
|
106
|
-
'.dsh-remote-sync-state.json',
|
|
107
|
-
]
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Load the user ignore file (gitignore syntax) and merge with defaults.
|
|
111
|
-
* Returns `{ patterns, fromFile }`.
|
|
112
|
-
*/
|
|
113
|
-
export function loadIgnore(ignoreFile) {
|
|
114
|
-
let fromFile = []
|
|
115
|
-
if (ignoreFile) {
|
|
116
|
-
try {
|
|
117
|
-
const text = readFileSync(ignoreFile, 'utf8')
|
|
118
|
-
fromFile = text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
119
|
-
} catch { /* missing file → defaults only */ }
|
|
120
|
-
}
|
|
121
|
-
const patterns = DEFAULT_IGNORE.concat(fromFile)
|
|
122
|
-
return { patterns, fromFile, matcher: compileIgnore(patterns) }
|
|
123
|
-
}
|
|
1
|
+
// dsh-remote — gitignore-style ignore matcher for mirror sync / remote search.
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
//
|
|
4
|
+
// Deliberately a SUBSET of gitignore semantics (documented, no surprises):
|
|
5
|
+
// - blank lines and `#` comments are skipped
|
|
6
|
+
// - `!` negation is NOT supported (later rules never un-ignore)
|
|
7
|
+
// - a trailing `/` matches directories only (and everything under them)
|
|
8
|
+
// - a pattern containing `/` (or starting with `/`) is anchored to the
|
|
9
|
+
// workspace root; otherwise it matches the basename at ANY depth
|
|
10
|
+
// - `*` matches any run of chars except `/`; `**` matches any run incl. `/`;
|
|
11
|
+
// `?` matches one char except `/`
|
|
12
|
+
// - character classes (`[abc]`) are not supported (treated literally)
|
|
13
|
+
|
|
14
|
+
/** Convert one glob segment to a regex source (no `/` crossing). A leading
|
|
15
|
+
* double-star-slash (`**` + `/`) matches zero or more directories (gitignore). */
|
|
16
|
+
function globToRe(glob) {
|
|
17
|
+
let out = ''
|
|
18
|
+
let i = 0
|
|
19
|
+
const s = String(glob)
|
|
20
|
+
if (s.startsWith('**/')) { out = '(?:.*/)?'; i = 3 }
|
|
21
|
+
while (i < s.length) {
|
|
22
|
+
const c = s[i]
|
|
23
|
+
if (c === '*') {
|
|
24
|
+
if (s[i + 1] === '*') { out += '.*'; i += 2; continue }
|
|
25
|
+
out += '[^/]*'; i++; continue
|
|
26
|
+
}
|
|
27
|
+
if (c === '?') { out += '[^/]'; i++; continue }
|
|
28
|
+
if ('\\^$+{}[]()|.'.includes(c)) out += '\\' + c
|
|
29
|
+
else out += c
|
|
30
|
+
i++
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compile a list of ignore patterns into a matcher.
|
|
37
|
+
* @param {string[]|string} patterns lines or an array of lines
|
|
38
|
+
* @returns {(relPath: string, isDir?: boolean) => boolean} true = ignored.
|
|
39
|
+
* `relPath` is POSIX-style ('/' separated, no leading '/').
|
|
40
|
+
*/
|
|
41
|
+
export function compileIgnore(patterns) {
|
|
42
|
+
const rules = []
|
|
43
|
+
const lines = Array.isArray(patterns) ? patterns : String(patterns || '').split('\n')
|
|
44
|
+
for (let raw of lines) {
|
|
45
|
+
let line = String(raw || '').trim()
|
|
46
|
+
if (!line || line.startsWith('#')) continue
|
|
47
|
+
let dirOnly = false
|
|
48
|
+
if (line.endsWith('/')) { dirOnly = true; line = line.slice(0, -1) }
|
|
49
|
+
let anchored = line.startsWith('/')
|
|
50
|
+
if (anchored) line = line.slice(1)
|
|
51
|
+
if (!line) continue
|
|
52
|
+
const hasSlash = line.includes('/')
|
|
53
|
+
const isAnchored = anchored || hasSlash
|
|
54
|
+
const segRe = globToRe(line)
|
|
55
|
+
let re
|
|
56
|
+
if (isAnchored) {
|
|
57
|
+
// Root-anchored: match the full path from the root.
|
|
58
|
+
re = dirOnly
|
|
59
|
+
? new RegExp('^' + segRe + '(/.*)?$')
|
|
60
|
+
: new RegExp('^' + segRe + '$')
|
|
61
|
+
} else {
|
|
62
|
+
// Basename at any depth; a dir pattern also covers everything under it.
|
|
63
|
+
re = dirOnly
|
|
64
|
+
? new RegExp('(^|/)' + segRe + '(/|$)')
|
|
65
|
+
: new RegExp('(^|/)' + segRe + '$')
|
|
66
|
+
}
|
|
67
|
+
rules.push({ re, dirOnly })
|
|
68
|
+
}
|
|
69
|
+
return function isIgnored(relPath, isDir) {
|
|
70
|
+
const rel = String(relPath || '').replace(/^\/+/, '').replace(/\\/g, '/')
|
|
71
|
+
if (!rel) return false
|
|
72
|
+
for (const r of rules) {
|
|
73
|
+
if (r.re.test(rel)) return true
|
|
74
|
+
}
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Built-in defaults: never pull/push these into/from a mirror. */
|
|
80
|
+
export const DEFAULT_IGNORE = [
|
|
81
|
+
'.git/',
|
|
82
|
+
'.svn/',
|
|
83
|
+
'.hg/',
|
|
84
|
+
'node_modules/',
|
|
85
|
+
'target/',
|
|
86
|
+
'dist/',
|
|
87
|
+
'build/',
|
|
88
|
+
'.venv/',
|
|
89
|
+
'venv/',
|
|
90
|
+
'__pycache__/',
|
|
91
|
+
'.idea/',
|
|
92
|
+
'.vscode/',
|
|
93
|
+
'*.o',
|
|
94
|
+
'*.a',
|
|
95
|
+
'*.so',
|
|
96
|
+
'*.dylib',
|
|
97
|
+
'*.exe',
|
|
98
|
+
'*.dll',
|
|
99
|
+
'*.class',
|
|
100
|
+
'*.jar',
|
|
101
|
+
'*.log',
|
|
102
|
+
'*.tmp',
|
|
103
|
+
'.DS_Store',
|
|
104
|
+
'Thumbs.db',
|
|
105
|
+
'.dsh-remote-meta.json',
|
|
106
|
+
'.dsh-remote-sync-state.json',
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load the user ignore file (gitignore syntax) and merge with defaults.
|
|
111
|
+
* Returns `{ patterns, fromFile }`.
|
|
112
|
+
*/
|
|
113
|
+
export function loadIgnore(ignoreFile) {
|
|
114
|
+
let fromFile = []
|
|
115
|
+
if (ignoreFile) {
|
|
116
|
+
try {
|
|
117
|
+
const text = readFileSync(ignoreFile, 'utf8')
|
|
118
|
+
fromFile = text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
119
|
+
} catch { /* missing file → defaults only */ }
|
|
120
|
+
}
|
|
121
|
+
const patterns = DEFAULT_IGNORE.concat(fromFile)
|
|
122
|
+
return { patterns, fromFile, matcher: compileIgnore(patterns) }
|
|
123
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import z from '@deepseek-ai/schemastery'
|
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import ssh2 from 'ssh2'
|
|
24
24
|
import { execFile } from 'node:child_process'
|
|
25
|
+
import zlib from 'node:zlib'
|
|
25
26
|
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, appendFileSync, unlinkSync, watch } from 'node:fs'
|
|
26
27
|
import { homedir } from 'node:os'
|
|
27
28
|
import path from 'node:path'
|
|
@@ -123,6 +124,33 @@ function remoteWorkspacesRoot() {
|
|
|
123
124
|
return path.join(dshBase(), 'remote-workspaces')
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** Safe path segment for a session id (mirrors DSH's encodeSegment: alnum + _- .).
|
|
128
|
+
* Session dirs keep the id mostly verbatim; sanitize anything exotic. */
|
|
129
|
+
function encodeSegmentSafe(id) {
|
|
130
|
+
const s = String(id || '').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
131
|
+
return s || 'session'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read the cwd from a session log header. Handles plain JSONL, .gz, and
|
|
135
|
+
* multi-frame zstd (decompress only the first frame, which holds the header).
|
|
136
|
+
* Returns the header cwd string, or '' when unreadable/absent. */
|
|
137
|
+
function readSessionHeaderCwd(file) {
|
|
138
|
+
try {
|
|
139
|
+
let buf = readFileSync(file)
|
|
140
|
+
if (/\.zstd$/.test(file)) {
|
|
141
|
+
buf = zlib.zstdDecompressSync(buf)
|
|
142
|
+
} else if (/\.gz$/.test(file)) {
|
|
143
|
+
buf = zlib.gunzipSync(buf)
|
|
144
|
+
}
|
|
145
|
+
const nl = buf.indexOf(10)
|
|
146
|
+
if (nl < 0) return ''
|
|
147
|
+
const head = JSON.parse(buf.subarray(0, nl).toString('utf8'))
|
|
148
|
+
return typeof head.cwd === 'string' ? head.cwd : ''
|
|
149
|
+
} catch {
|
|
150
|
+
return ''
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
126
154
|
/** Local mirrors of one remote host. */
|
|
127
155
|
function mirrorRootFor(host, user, port) {
|
|
128
156
|
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
@@ -1479,7 +1507,7 @@ export async function apply(ctx, config) {
|
|
|
1479
1507
|
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
1480
1508
|
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
1481
1509
|
pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
|
|
1482
|
-
env: { type: 'object', description: 'Extra environment variables (string values)' },
|
|
1510
|
+
env: { type: 'object', additionalProperties: true, description: 'Extra environment variables (string values)' },
|
|
1483
1511
|
},
|
|
1484
1512
|
output: textOut,
|
|
1485
1513
|
async execute(args) {
|
|
@@ -1800,6 +1828,93 @@ export async function apply(ctx, config) {
|
|
|
1800
1828
|
sendJson(res, 405, { error: 'method not allowed' })
|
|
1801
1829
|
},
|
|
1802
1830
|
},
|
|
1831
|
+
{
|
|
1832
|
+
// Map a LOCAL path (typically a session cwd that sits inside a remote
|
|
1833
|
+
// mirror dir) to the remote path it mirrors, by reading each mirror
|
|
1834
|
+
// dir's .dsh-remote-meta.json. Falls back to the machine workspace so
|
|
1835
|
+
// the sidebar still shows something sensible for non-mirror cwds.
|
|
1836
|
+
// Accepts either ?local=<abs path> or ?sessionId=<id> (resolved via
|
|
1837
|
+
// the host sessions service header.cwd).
|
|
1838
|
+
kind: 'exact',
|
|
1839
|
+
path: '/dsh-remote/resolve-mirror',
|
|
1840
|
+
handler: async (req, res) => {
|
|
1841
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1842
|
+
try {
|
|
1843
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1844
|
+
let local = q.get('local') ? decodeURIComponent(q.get('local')) : ''
|
|
1845
|
+
const sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : ''
|
|
1846
|
+
let resolvedVia = 'query'
|
|
1847
|
+
if (!local && sessionId) {
|
|
1848
|
+
// 1) Live in-memory session header (active sessions only).
|
|
1849
|
+
try {
|
|
1850
|
+
const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null
|
|
1851
|
+
const session = sessions && typeof sessions.get === 'function' ? sessions.get(sessionId) : null
|
|
1852
|
+
const header = session && session.header ? session.header : null
|
|
1853
|
+
if (header && header.cwd) {
|
|
1854
|
+
local = String(header.cwd)
|
|
1855
|
+
resolvedVia = 'session'
|
|
1856
|
+
}
|
|
1857
|
+
} catch { /* sessions service unavailable */ }
|
|
1858
|
+
// 2) Durable session log header (works for historical sessions too):
|
|
1859
|
+
// walk $DSH_HOME/sessions/<projectKey>/<sessionId>/session.jsonl.zstd
|
|
1860
|
+
// and read the header line (first zstd frame) for cwd.
|
|
1861
|
+
if (!local) {
|
|
1862
|
+
try {
|
|
1863
|
+
const sessionsRoot = path.join(dshBase(), 'sessions')
|
|
1864
|
+
if (existsSync(sessionsRoot)) {
|
|
1865
|
+
const targetDir = encodeSegmentSafe(sessionId)
|
|
1866
|
+
for (const projDir of readdirSync(sessionsRoot)) {
|
|
1867
|
+
const projPath = path.join(sessionsRoot, projDir)
|
|
1868
|
+
if (!statSync(projPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1869
|
+
const sessDir = path.join(projPath, targetDir)
|
|
1870
|
+
if (!statSync(sessDir, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1871
|
+
const logFile = ['session.jsonl.zstd', 'session.jsonl', 'session.jsonl.gz'].map((n) => path.join(sessDir, n)).find((p) => existsSync(p))
|
|
1872
|
+
if (!logFile) continue
|
|
1873
|
+
const cwd = readSessionHeaderCwd(logFile)
|
|
1874
|
+
if (cwd) {
|
|
1875
|
+
local = cwd
|
|
1876
|
+
resolvedVia = 'session-log'
|
|
1877
|
+
break
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
} catch { /* session log scan failed */ }
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
const remote = status().workspace || ''
|
|
1885
|
+
const root = remoteWorkspacesRoot()
|
|
1886
|
+
const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
|
|
1887
|
+
let matched = ''
|
|
1888
|
+
let matchedRemote = ''
|
|
1889
|
+
if (local && existsSync(root)) {
|
|
1890
|
+
const base = norm(local)
|
|
1891
|
+
for (const hostDir of readdirSync(root)) {
|
|
1892
|
+
const hostPath = path.join(root, hostDir)
|
|
1893
|
+
if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1894
|
+
for (const mirrorDir of readdirSync(hostPath)) {
|
|
1895
|
+
const metaPath = path.join(hostPath, mirrorDir, '.dsh-remote-meta.json')
|
|
1896
|
+
if (!existsSync(metaPath)) continue
|
|
1897
|
+
try {
|
|
1898
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
1899
|
+
const mirrorAbs = norm(path.join(hostPath, mirrorDir))
|
|
1900
|
+
// Exact mirror dir match, or the local path is inside the mirror dir.
|
|
1901
|
+
if (mirrorAbs === base || base.startsWith(mirrorAbs + path.sep) || base.startsWith(mirrorAbs + '/')) {
|
|
1902
|
+
if (mirrorAbs.length > matched.length) {
|
|
1903
|
+
matched = mirrorAbs
|
|
1904
|
+
matchedRemote = String(meta.remotePath || '')
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
} catch { /* skip unparsable meta */ }
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
const remotePath = matchedRemote || remote
|
|
1912
|
+
return sendJson(res, 200, { local, remotePath, mirrorDir: matched || null, fallback: !matchedRemote, resolvedVia })
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1915
|
+
}
|
|
1916
|
+
},
|
|
1917
|
+
},
|
|
1803
1918
|
{
|
|
1804
1919
|
kind: 'exact',
|
|
1805
1920
|
path: '/dsh-remote/connect',
|