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/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 dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
2184
- if (dp && typeof dp.capability === 'function') {
2211
+ const cap = await localPickCapability()
2212
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
2185
2213
  try {
2186
- const cap = await Promise.resolve(dp.capability())
2187
- if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
2188
- const pickAbort = new AbortController()
2189
- const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
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',