dsh-remote 0.8.5 → 0.8.7
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 +231 -220
- package/README.zh.md +178 -178
- package/cordis.patch.yml +53 -24
- package/lib/client.js +112 -7
- 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 +92 -9
- 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
|
@@ -1818,6 +1818,34 @@ export async function apply(ctx, config) {
|
|
|
1818
1818
|
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1819
1819
|
}
|
|
1820
1820
|
|
|
1821
|
+
/** The ctx.directoryPicker capability object, or null when the service is
|
|
1822
|
+
* absent/unusable. Never throws — callers fall through to pickLocalNative. */
|
|
1823
|
+
const localPickCapability = async () => {
|
|
1824
|
+
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
1825
|
+
if (!dp || typeof dp.capability !== 'function') return null
|
|
1826
|
+
try {
|
|
1827
|
+
return await Promise.resolve(dp.capability())
|
|
1828
|
+
} catch {
|
|
1829
|
+
return null
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/** Windows has no single filesystem root — enumerate fixed/mapped drive
|
|
1834
|
+
* letters so the in-app browser can switch between them (the browse
|
|
1835
|
+
* backend's crumbs stop at the current drive's root). Empty on POSIX. */
|
|
1836
|
+
const listLocalDrives = () => {
|
|
1837
|
+
if (process.platform !== 'win32') return []
|
|
1838
|
+
const out = []
|
|
1839
|
+
for (let i = 67; i <= 90; i++) { // C..Z (A/B are legacy floppy — skip)
|
|
1840
|
+
const root = String.fromCharCode(i) + ':\\'
|
|
1841
|
+
try {
|
|
1842
|
+
statSync(root)
|
|
1843
|
+
out.push({ name: root.slice(0, -1), path: root })
|
|
1844
|
+
} catch {}
|
|
1845
|
+
}
|
|
1846
|
+
return out
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1821
1849
|
// ── route helpers ─────────────────────────────────────────────────────────
|
|
1822
1850
|
const routes = [
|
|
1823
1851
|
{
|
|
@@ -2180,25 +2208,35 @@ export async function apply(ctx, config) {
|
|
|
2180
2208
|
try {
|
|
2181
2209
|
let outcome = null
|
|
2182
2210
|
let via = 'service'
|
|
2183
|
-
const
|
|
2184
|
-
if (
|
|
2211
|
+
const cap = await localPickCapability()
|
|
2212
|
+
if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
|
|
2185
2213
|
try {
|
|
2186
|
-
const
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
pickAbort.abort()
|
|
2191
|
-
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
2192
|
-
}
|
|
2214
|
+
const pickAbort = new AbortController()
|
|
2215
|
+
const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
|
|
2216
|
+
pickAbort.abort()
|
|
2217
|
+
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
2193
2218
|
} catch (err) {
|
|
2194
2219
|
outcome = null
|
|
2195
2220
|
}
|
|
2196
2221
|
}
|
|
2222
|
+
// Prefer a REAL OS dialog over the browse backend: operators expect
|
|
2223
|
+
// an Explorer-style chooser, and the own spawn (PowerShell
|
|
2224
|
+
// FolderBrowserDialog / osascript / zenity-kdialog) works even where
|
|
2225
|
+
// the host mounted browse — DSH Desktop (Electron) deliberately
|
|
2226
|
+
// mounts browse on win32 because the native backend's koffi dialog
|
|
2227
|
+
// worker cannot run under Electron, but a plain child process can.
|
|
2197
2228
|
if (!outcome) {
|
|
2198
2229
|
via = 'own'
|
|
2199
2230
|
try {
|
|
2200
2231
|
outcome = await pickLocalNative()
|
|
2201
2232
|
} catch (err) {
|
|
2233
|
+
// No usable OS dialog (headless host, missing chooser binary):
|
|
2234
|
+
// fall back to the host's browse capability — fs-only, works
|
|
2235
|
+
// display-less — served as an in-app browser by the client
|
|
2236
|
+
// (backed by /dsh-remote/local-list + /dsh-remote/local-mkdir).
|
|
2237
|
+
if (cap && cap.kind === 'browse' && typeof cap.list === 'function') {
|
|
2238
|
+
return sendJson(res, 200, { ok: true, kind: 'browse', via: 'browse' })
|
|
2239
|
+
}
|
|
2202
2240
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
|
|
2203
2241
|
}
|
|
2204
2242
|
}
|
|
@@ -2209,6 +2247,51 @@ export async function apply(ctx, config) {
|
|
|
2209
2247
|
}
|
|
2210
2248
|
},
|
|
2211
2249
|
},
|
|
2250
|
+
{
|
|
2251
|
+
kind: 'exact',
|
|
2252
|
+
path: '/dsh-remote/local-list',
|
|
2253
|
+
handler: async (req, res) => {
|
|
2254
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2255
|
+
try {
|
|
2256
|
+
const cap = await localPickCapability()
|
|
2257
|
+
if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function') return sendJson(res, 400, { ok: false, error: '当前目录选择器不是浏览后端,无法列出本机目录' })
|
|
2258
|
+
const m = (req.url || '').match(/path=([^&]*)/)
|
|
2259
|
+
const raw = m ? decodeURIComponent(m[1]) : ''
|
|
2260
|
+
// No path → the browse backend lists the host home directory.
|
|
2261
|
+
try {
|
|
2262
|
+
const out = await Promise.resolve(cap.list(raw ? raw : undefined))
|
|
2263
|
+
return sendJson(res, 200, { ok: true, ...out, drives: listLocalDrives() })
|
|
2264
|
+
} catch (listErr) {
|
|
2265
|
+
return sendJson(res, 400, { ok: false, error: String((listErr && listErr.message) || listErr), code: (listErr && listErr.code) || 'directory-unreadable' })
|
|
2266
|
+
}
|
|
2267
|
+
} catch (err) {
|
|
2268
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2269
|
+
}
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2272
|
+
{
|
|
2273
|
+
kind: 'exact',
|
|
2274
|
+
path: '/dsh-remote/local-mkdir',
|
|
2275
|
+
handler: async (req, res) => {
|
|
2276
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2277
|
+
try {
|
|
2278
|
+
const cap = await localPickCapability()
|
|
2279
|
+
if (!cap || cap.kind !== 'browse' || typeof cap.createDirectory !== 'function') return sendJson(res, 400, { ok: false, error: '当前目录选择器不是浏览后端,无法新建文件夹' })
|
|
2280
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
2281
|
+
const p = String(payload.path || '')
|
|
2282
|
+
const name = String(payload.name || '')
|
|
2283
|
+
if (!p.trim() || !name.trim()) return sendJson(res, 400, { ok: false, error: 'path and name required' })
|
|
2284
|
+
try {
|
|
2285
|
+
const created = await Promise.resolve(cap.createDirectory(p, name))
|
|
2286
|
+
return sendJson(res, 200, { ok: true, path: created })
|
|
2287
|
+
} catch (mkErr) {
|
|
2288
|
+
return sendJson(res, 400, { ok: false, error: String((mkErr && mkErr.message) || mkErr), code: (mkErr && mkErr.code) || 'directory-create-failed' })
|
|
2289
|
+
}
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2292
|
+
}
|
|
2293
|
+
},
|
|
2294
|
+
},
|
|
2212
2295
|
{
|
|
2213
2296
|
kind: 'exact',
|
|
2214
2297
|
path: '/dsh-remote/machines',
|