dsh-remote 0.7.5 → 0.8.2

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.
@@ -0,0 +1,107 @@
1
+ // dsh-remote — optional OS-keychain password storage.
2
+ // Machines keep their password in the OS credential store instead of the
3
+ // plaintext machines.json when the user enables "加密保存密码" per machine.
4
+ // Every backend is best-effort: any failure resolves to {ok:false} and the
5
+ // caller falls back to plaintext (the feature must never block connecting).
6
+ //
7
+ // Backends:
8
+ // darwin → `security` (login keychain, generic password)
9
+ // win32 → DPAPI via PowerShell (CurrentUser scope), files under
10
+ // $DSH_HOME/remote-workspaces/.secrets/
11
+ // linux → `secret-tool` (libsecret / gnome-keyring), optional
12
+ // else → unsupported (plain)
13
+ import { execFile } from 'node:child_process'
14
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
15
+ import { join as joinPath } from 'node:path'
16
+
17
+ const SERVICE = 'dsh-remote'
18
+ const TIMEOUT = 8000
19
+
20
+ function run(bin, args, input) {
21
+ return new Promise((resolve, reject) => {
22
+ execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
23
+ if (err) return reject(err)
24
+ resolve(String(stdout || '').trim())
25
+ })
26
+ })
27
+ }
28
+
29
+ export function platformBackend() {
30
+ if (process.platform === 'darwin') return 'keychain'
31
+ if (process.platform === 'win32') return 'windows'
32
+ return 'secret' // linux (secret-tool may be absent → falls back)
33
+ }
34
+
35
+ function account(machineId) {
36
+ // Only machine ids are stored; sanitize just in case.
37
+ return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
38
+ }
39
+
40
+ /** Save a password to the OS store. Resolves {ok, backend} — ok:false on any
41
+ * failure (caller falls back to plaintext). */
42
+ export async function saveSecret(machineId, password, secretsDir) {
43
+ const acc = account(machineId)
44
+ try {
45
+ if (process.platform === 'darwin') {
46
+ await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
47
+ return { ok: true, backend: 'keychain' }
48
+ }
49
+ if (process.platform === 'win32') {
50
+ // DPAPI-encrypt and store under the harness home.
51
+ const script =
52
+ `$p='${String(password).replace(/'/g, "''")}';` +
53
+ `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
54
+ `[Convert]::ToBase64String($s)`
55
+ const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
56
+ if (!b64) return { ok: false, backend: 'windows' }
57
+ mkdirSync(secretsDir || '.', { recursive: true })
58
+ writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
59
+ return { ok: true, backend: 'windows' }
60
+ }
61
+ if (process.platform === 'linux') {
62
+ await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
63
+ return { ok: true, backend: 'secret' }
64
+ }
65
+ } catch { /* fall through */ }
66
+ return { ok: false, backend: platformBackend() }
67
+ }
68
+
69
+ /** Fetch a stored password. Resolves string|null. */
70
+ export async function getSecret(machineId, secretsDir) {
71
+ const acc = account(machineId)
72
+ try {
73
+ if (process.platform === 'darwin') {
74
+ return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
75
+ }
76
+ if (process.platform === 'win32') {
77
+ const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
78
+ if (!b64) return null
79
+ const script =
80
+ `$s=[Convert]::FromBase64String('${b64}');` +
81
+ `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
82
+ return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
83
+ }
84
+ if (process.platform === 'linux') {
85
+ return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
86
+ }
87
+ } catch { /* fall through */ }
88
+ return null
89
+ }
90
+
91
+ /** Delete a stored password (idempotent). */
92
+ export async function deleteSecret(machineId, secretsDir) {
93
+ const acc = account(machineId)
94
+ try {
95
+ if (process.platform === 'darwin') {
96
+ await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
97
+ return
98
+ }
99
+ if (process.platform === 'win32') {
100
+ try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
101
+ return
102
+ }
103
+ if (process.platform === 'linux') {
104
+ await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
105
+ }
106
+ } catch { /* best effort */ }
107
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,78 @@
1
+ // dsh-remote — SSH/SFTP error classification → actionable hints.
2
+ // Pure function so it is unit-testable and reused by tools, slash commands,
3
+ // the test-connect route and the settings UI.
4
+
5
+ /** Classify an error thrown by the ssh pool / sftp wrapper.
6
+ * @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
7
+ */
8
+ export function classifyError(err, opts = {}) {
9
+ const msg = String((err && err.message) || err || '')
10
+ const { host, port } = opts
11
+ const where = host ? ` (${host}:${port || 22})` : ''
12
+
13
+ if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
14
+ return {
15
+ category: 'hostkey',
16
+ hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
17
+ raw: msg,
18
+ }
19
+ }
20
+ if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
21
+ return {
22
+ category: 'auth',
23
+ hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
24
+ raw: msg,
25
+ }
26
+ }
27
+ if (/no credentials/i.test(msg)) {
28
+ return {
29
+ category: 'credentials',
30
+ hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
31
+ raw: msg,
32
+ }
33
+ }
34
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
35
+ return {
36
+ category: 'network',
37
+ hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
38
+ raw: msg,
39
+ }
40
+ }
41
+ if (/ECONNREFUSED|connection refused/i.test(msg)) {
42
+ return {
43
+ category: 'network',
44
+ hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
45
+ raw: msg,
46
+ }
47
+ }
48
+ if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
49
+ return {
50
+ category: 'sftp',
51
+ hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
52
+ raw: msg,
53
+ }
54
+ }
55
+ if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
56
+ return {
57
+ category: 'timeout',
58
+ hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
59
+ raw: msg,
60
+ }
61
+ }
62
+ if (/cannot read private key/i.test(msg)) {
63
+ return {
64
+ category: 'credentials',
65
+ hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
66
+ raw: msg,
67
+ }
68
+ }
69
+ return { category: 'other', hint: msg, raw: msg }
70
+ }
71
+
72
+ /** One-line friendly message: hint (+ raw when it adds information). */
73
+ export function friendlyMessage(err, opts = {}) {
74
+ const c = classifyError(err, opts)
75
+ if (c.category === 'other') return c.raw
76
+ if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
77
+ return c.hint
78
+ }
@@ -0,0 +1,219 @@
1
+ // dsh-remote — SSH port forwarding manager (local & reverse tunnels).
2
+ //
3
+ // Local forward: listen on 127.0.0.1:<listenPort> → every connection is
4
+ // piped through the SSH channel to <targetHost>:<targetPort> on the remote.
5
+ // Reverse forward: ask the remote to listen on 127.0.0.1:<listenPort> and pipe
6
+ // incoming connections back to <targetHost>:<targetPort> on the LOCAL side
7
+ // (ssh2 `forwardIn` + the client `tcpip` event; requires AllowTcpForwarding on
8
+ // the remote sshd).
9
+ //
10
+ // Definitions persist in forwards.json; forwards become active only while the
11
+ // SSH connection is up (auto-restart on connect when `autoStart: true`), and
12
+ // every tunnel is torn down when the pool disconnects — reverse tunnels are
13
+ // never auto-restarted (they would re-expose a local port without asking).
14
+ import net from 'node:net'
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
16
+ import path from 'node:path'
17
+
18
+ export class ForwardManager {
19
+ /** @param {object} pool SshPool (needs .connect() returning the ssh2 client). */
20
+ constructor(pool, { file } = {}) {
21
+ this.pool = pool
22
+ this.file = file || null
23
+ this.defs = this._load()
24
+ this.servers = new Map() // id → { server | remoteListen, sockets:Set, client }
25
+ this.client = null
26
+ this.onTcpip = null
27
+ this.onClose = null
28
+ this.attach = this.attach.bind(this)
29
+ }
30
+
31
+ _load() {
32
+ if (!this.file) return []
33
+ try {
34
+ const j = JSON.parse(readFileSync(this.file, 'utf8'))
35
+ if (Array.isArray(j.defs)) return j.defs
36
+ } catch {}
37
+ return []
38
+ }
39
+
40
+ _save() {
41
+ if (!this.file) return
42
+ try {
43
+ mkdirSync(path.dirname(this.file), { recursive: true })
44
+ writeFileSync(this.file, JSON.stringify({ defs: this.defs }, null, 2))
45
+ } catch {}
46
+ }
47
+
48
+ /** Called by the pool after a successful connect: restore autoStart tunnels. */
49
+ attach(client) {
50
+ if (this.client === client) return
51
+ this.client = client
52
+ this.onTcpip = (info, accept, reject) => this._handleTcpip(info, accept, reject)
53
+ client.on('tcpip', this.onTcpip)
54
+ this.onClose = () => this.stopAll()
55
+ client.on('close', this.onClose)
56
+ for (const d of this.defs) {
57
+ if (d.autoStart && d.direction === 'local' && !this.servers.has(d.id)) {
58
+ this.start(d).catch(() => {})
59
+ }
60
+ }
61
+ }
62
+
63
+ detach() {
64
+ this.stopAll()
65
+ if (this.client) {
66
+ if (this.onTcpip) this.client.removeListener('tcpip', this.onTcpip)
67
+ if (this.onClose) this.client.removeListener('close', this.onClose)
68
+ }
69
+ this.client = null
70
+ this.onTcpip = null
71
+ this.onClose = null
72
+ }
73
+
74
+ list() {
75
+ return this.defs.map((d) => ({
76
+ id: d.id,
77
+ direction: d.direction,
78
+ listenPort: d.listenPort,
79
+ targetHost: d.targetHost,
80
+ targetPort: d.targetPort,
81
+ machineId: d.machineId || null,
82
+ autoStart: !!d.autoStart,
83
+ active: this.servers.has(d.id),
84
+ }))
85
+ }
86
+
87
+ _defId() {
88
+ return 'fwd-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
89
+ }
90
+
91
+ /** Create a forward definition (persisted). Does NOT start it. */
92
+ define({ direction = 'local', listenPort, targetHost, targetPort, autoStart = false, machineId } = {}) {
93
+ const d = {
94
+ id: this._defId(),
95
+ direction: direction === 'reverse' ? 'reverse' : 'local',
96
+ listenPort: Number(listenPort),
97
+ targetHost: String(targetHost || '127.0.0.1'),
98
+ targetPort: Number(targetPort),
99
+ autoStart: !!autoStart,
100
+ machineId: machineId || null,
101
+ }
102
+ this.defs.push(d)
103
+ this._save()
104
+ return d
105
+ }
106
+
107
+ remove(id) {
108
+ this.stop(id)
109
+ const i = this.defs.findIndex((d) => d.id === id)
110
+ if (i >= 0) { this.defs.splice(i, 1); this._save(); return true }
111
+ return false
112
+ }
113
+
114
+ async start(def) {
115
+ const d = this.defs.find((x) => x.id === def.id) || def
116
+ if (this.servers.has(d.id)) return { ok: true, active: true }
117
+ let client = await this.pool.connect()
118
+ if (!this.client) this.attach(client)
119
+ if (this.client !== client) {
120
+ // The pool reconnected while we were connecting — retry on the live client.
121
+ client = this.client
122
+ }
123
+ try {
124
+ if (d.direction === 'reverse') {
125
+ await this._startReverse(d, client)
126
+ } else {
127
+ await this._startLocal(d, client)
128
+ }
129
+ return { ok: true, active: true }
130
+ } catch (err) {
131
+ return { ok: false, error: String((err && err.message) || err) }
132
+ }
133
+ }
134
+
135
+ _startLocal(d, client) {
136
+ return new Promise((resolve, reject) => {
137
+ const server = net.createServer((socket) => {
138
+ client.forwardOut('127.0.0.1', 0, d.targetHost, d.targetPort, (err, channel) => {
139
+ if (err) { socket.destroy(); return }
140
+ socket.pipe(channel)
141
+ channel.pipe(socket)
142
+ const kill = () => { try { socket.destroy() } catch {}; try { channel.close() } catch {} }
143
+ socket.on('error', kill)
144
+ channel.on('error', kill)
145
+ socket.on('close', kill)
146
+ channel.on('close', kill)
147
+ })
148
+ })
149
+ server.on('error', (e) => reject(new Error(`本地转发 ${d.listenPort} 启动失败: ${e.message}`)))
150
+ server.listen(d.listenPort, '127.0.0.1', () => {
151
+ this.servers.set(d.id, { kind: 'local', server, sockets: new Set(), client })
152
+ resolve()
153
+ })
154
+ })
155
+ }
156
+
157
+ _startReverse(d, client) {
158
+ return new Promise((resolve, reject) => {
159
+ // ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
160
+ // which in ssh2 >= 1.16 is stream-local only).
161
+ if (typeof client.forwardIn !== 'function') {
162
+ return reject(new Error('此 ssh2 版本不支持反向转发 (forwardIn)'))
163
+ }
164
+ client.forwardIn('127.0.0.1', d.listenPort, (err) => {
165
+ if (err) return reject(new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`))
166
+ this.servers.set(d.id, { kind: 'reverse', sockets: new Set(), client })
167
+ resolve()
168
+ })
169
+ })
170
+ }
171
+
172
+ _handleTcpip(info, accept, reject) {
173
+ for (const entry of this.servers.values()) {
174
+ if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
175
+ const channel = accept()
176
+ const socket = net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
177
+ socket.pipe(channel)
178
+ channel.pipe(socket)
179
+ entry.sockets.add(socket)
180
+ const kill = () => {
181
+ try { socket.destroy() } catch {}
182
+ try { channel.close() } catch {}
183
+ entry.sockets.delete(socket)
184
+ }
185
+ socket.on('error', kill)
186
+ channel.on('error', kill)
187
+ socket.on('close', kill)
188
+ return
189
+ }
190
+ }
191
+ reject()
192
+ }
193
+
194
+ _defFor(entry) {
195
+ for (const d of this.defs) {
196
+ if (this.servers.has(d.id) && this.servers.get(d.id) === entry) return d
197
+ }
198
+ return { listenPort: 0, targetHost: '127.0.0.1', targetPort: 0 }
199
+ }
200
+
201
+ stop(id) {
202
+ const entry = this.servers.get(id)
203
+ if (!entry) return
204
+ try {
205
+ if (entry.kind === 'local') {
206
+ entry.server.close()
207
+ for (const s of entry.sockets) { try { s.destroy() } catch {} }
208
+ } else if (entry.client && typeof entry.client.unforwardIn === 'function') {
209
+ const d = this._defFor(entry)
210
+ entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})
211
+ }
212
+ } catch {}
213
+ this.servers.delete(id)
214
+ }
215
+
216
+ stopAll() {
217
+ for (const id of [...this.servers.keys()]) this.stop(id)
218
+ }
219
+ }
package/lib/hostkey.js ADDED
@@ -0,0 +1,48 @@
1
+ // dsh-remote — host-key fingerprint helpers (TOFU). Pure functions split out
2
+ // of lib/index.js so the ssh2 hostVerifier contract (raw Buffer blob since
3
+ // ssh2 v1.17) is unit-tested against the REAL wire shape — the v0.6.7 bug
4
+ // class ("mock object hid a contract drift") must not regress.
5
+ import { createHash } from 'node:crypto'
6
+
7
+ /**
8
+ * Extract the SSH host-key algorithm name from a raw SSH host-key blob
9
+ * (SSH wire format: `uint32 len` + algorithm string + key data).
10
+ */
11
+ export function blobAlgorithm(blob) {
12
+ if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
13
+ try {
14
+ const len = blob.readUInt32BE(0)
15
+ return blob.toString('utf8', 4, 4 + len)
16
+ } catch {
17
+ return ''
18
+ }
19
+ }
20
+
21
+ /**
22
+ * SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
23
+ * in a known_hosts file (without the `SHA256:` prefix).
24
+ *
25
+ * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
26
+ * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
27
+ * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
28
+ * argument must be of type string or an instance of Buffer…" on EVERY connect
29
+ * (v0.6.6 bug). Accept both shapes defensively.
30
+ */
31
+ export function keyFingerprint(key) {
32
+ const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
33
+ if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
34
+ return createHash('sha256').update(blob).digest('base64')
35
+ }
36
+
37
+ /** Build a fake-but-wire-shaped host-key blob for tests:
38
+ * `string(algo) string(32 bytes)`. */
39
+ export function makeKeyBlob(algo, seed) {
40
+ const algoBuf = Buffer.from(algo, 'utf8')
41
+ const data = Buffer.alloc(32, seed || 1)
42
+ const blob = Buffer.alloc(4 + algoBuf.length + 4 + data.length)
43
+ blob.writeUInt32BE(algoBuf.length, 0)
44
+ algoBuf.copy(blob, 4)
45
+ blob.writeUInt32BE(data.length, 4 + algoBuf.length)
46
+ data.copy(blob, 8 + algoBuf.length)
47
+ return blob
48
+ }
package/lib/ignore.js ADDED
@@ -0,0 +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
+ }