@hunan36/dsh-ssh-helper 0.0.0-stage → 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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Read-only remote deployment discovery.
3
+ *
4
+ * `scan` searches common deployment roots for project markers and reports
5
+ * candidate directories; `inspect` reads one directory's shape (top-level
6
+ * entries, markers, git state and running services). The generated shell is
7
+ * conservative POSIX (works on macOS and Linux), never reads file contents and
8
+ * never runs `stat -c`, `find -printf`, `ss`, `readlink -f` or `xargs -r`.
9
+ *
10
+ * @module host/discover
11
+ */
12
+
13
+ import path from 'node:path'
14
+ import { shQuote, validateRemotePath } from './shell.js'
15
+
16
+ /** Default roots for a scan. */
17
+ const DEFAULT_ROOTS = ['/opt', '/srv', '/var/www', '/data', '/app', '/home']
18
+ /** Marker file names searched by a scan. */
19
+ const MARKER_NAMES = [
20
+ '.git',
21
+ 'docker-compose.yml',
22
+ 'docker-compose.yaml',
23
+ 'compose.yml',
24
+ 'compose.yaml',
25
+ 'package.json',
26
+ 'pom.xml',
27
+ 'build.gradle',
28
+ 'go.mod',
29
+ 'requirements.txt',
30
+ 'manage.py',
31
+ 'index.html',
32
+ 'Caddyfile',
33
+ 'nginx.conf',
34
+ ]
35
+ /** Compose markers that imply a compose deployment. */
36
+ const COMPOSE_MARKERS = new Set(['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'])
37
+ /** Maximum retained raw netstat lines. */
38
+ const MAX_RAW_LINES = 100
39
+ /** Default discovery timeout (60 s). */
40
+ const DEFAULT_TIMEOUT_MS = 60_000
41
+ /** Minimum accepted timeout (1 s). */
42
+ const MIN_TIMEOUT_MS = 1_000
43
+ /** Maximum accepted timeout (10 min). */
44
+ const MAX_TIMEOUT_MS = 600_000
45
+
46
+ /**
47
+ * Build one tagged error.
48
+ * @param {string} code - machine-readable code.
49
+ * @param {string} message - Chinese message.
50
+ * @returns {Error & { code: string }} tagged error.
51
+ */
52
+ function taggedError(code, message) {
53
+ const error = new Error(message)
54
+ error.code = code
55
+ return error
56
+ }
57
+
58
+ /** @param {string} message @returns {Error} invalid path error. */
59
+ function invalidPath(message) {
60
+ return taggedError('invalid-path', message)
61
+ }
62
+
63
+ /**
64
+ * Clamp one numeric option.
65
+ * @param {unknown} value - raw value.
66
+ * @param {number} fallback - default.
67
+ * @param {number} min - lower bound.
68
+ * @param {number} max - upper bound.
69
+ * @returns {number} clamped integer.
70
+ */
71
+ function clampInt(value, fallback, min, max) {
72
+ if (value === undefined || value === null || value === '') return fallback
73
+ const numeric = Number(value)
74
+ if (!Number.isFinite(numeric)) return fallback
75
+ return Math.min(max, Math.max(min, Math.round(numeric)))
76
+ }
77
+
78
+ /**
79
+ * Normalize one scan root. Scans are read-only, so system roots such as
80
+ * `/home` or `/usr` are allowed; only the filesystem root and unsafe strings
81
+ * are rejected.
82
+ * @param {unknown} raw - candidate root.
83
+ * @returns {string} normalized absolute root.
84
+ */
85
+ function normalizeScanRoot(raw) {
86
+ if (typeof raw !== 'string') throw invalidPath('扫描根目录无效')
87
+ const text = raw.trim()
88
+ if (text === '') throw invalidPath('扫描根目录不能为空')
89
+ if (text.length > 1024) throw invalidPath('扫描根目录过长')
90
+ if (!text.startsWith('/')) throw invalidPath('扫描根目录必须是绝对路径')
91
+ if (/[\u0000-\u001f\u007f]/.test(text)) throw invalidPath('扫描根目录不能包含控制字符')
92
+ if (text.split('/').some((segment) => segment === '..')) throw invalidPath('扫描根目录不能包含 ".." 片段')
93
+ const normalized = path.posix.normalize(text)
94
+ if (normalized === '/') throw invalidPath('不允许扫描文件系统根目录')
95
+ return normalized
96
+ }
97
+
98
+ /**
99
+ * Build a read-only scan command.
100
+ * @param {{ roots?: string[], maxDepth?: number, limit?: number }} [options] - scan options.
101
+ * @returns {string} shell command with `###OS`, `###USER`, `###FIND`, `###PORTS`
102
+ * and `###UNITS` sections.
103
+ */
104
+ export function buildScanCommand({ roots, maxDepth, limit } = {}) {
105
+ const rawRoots = roots === undefined ? DEFAULT_ROOTS : roots
106
+ if (!Array.isArray(rawRoots) || rawRoots.length === 0) throw invalidPath('扫描根目录不能为空')
107
+ const normalizedRoots = rawRoots.map(normalizeScanRoot)
108
+ const depth = clampInt(maxDepth, 3, 1, 4)
109
+ const maxCandidates = clampInt(limit, 80, 1, 200)
110
+ const findHead = Math.min(4000, Math.max(40, maxCandidates * 10))
111
+ const rootArgs = normalizedRoots.map(shQuote).join(' ')
112
+ const markerExpr = MARKER_NAMES.map((name) => `-name ${shQuote(name)}`).join(' -o ')
113
+ const findExpr = `\\( ${markerExpr} -o -name '*.jar' -o -name '*.war' \\)`
114
+
115
+ return [
116
+ "printf '###OS\\n'",
117
+ 'uname -srm 2>/dev/null',
118
+ "printf '\\n###USER\\n'",
119
+ 'id -un 2>/dev/null',
120
+ "printf '\\n###FIND\\n'",
121
+ `find ${rootArgs} -maxdepth ${depth} ${findExpr} -print 2>/dev/null | head -n ${findHead}`,
122
+ "printf '\\n###PORTS\\n'",
123
+ 'if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null; else netstat -an 2>/dev/null; fi | head -n 120',
124
+ "printf '\\n###UNITS\\n'",
125
+ 'systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | head -n 80',
126
+ "docker ps --format '{{.Names}}\\t{{.Image}}' 2>/dev/null | head -n 40",
127
+ ].join('\n')
128
+ }
129
+
130
+ /**
131
+ * Split sectioned shell output.
132
+ * @param {string} text - command output.
133
+ * @returns {Map<string, string[]>} section name to raw lines.
134
+ */
135
+ function splitSections(text) {
136
+ /** @type {Map<string, string[]>} */
137
+ const sections = new Map()
138
+ /** @type {string | null} */
139
+ let current = null
140
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
141
+ const match = /^###([A-Z_]+)$/.exec(rawLine.trim())
142
+ if (match) {
143
+ current = match[1]
144
+ if (!sections.has(current)) sections.set(current, [])
145
+ continue
146
+ }
147
+ if (current !== null) sections.get(current).push(rawLine)
148
+ }
149
+ return sections
150
+ }
151
+
152
+ /**
153
+ * First non-empty trimmed line of one section.
154
+ * @param {string[] | undefined} lines - section lines.
155
+ * @returns {string} value.
156
+ */
157
+ function firstLine(lines) {
158
+ if (!Array.isArray(lines)) return ''
159
+ for (const line of lines) {
160
+ const trimmed = line.trim()
161
+ if (trimmed !== '') return trimmed
162
+ }
163
+ return ''
164
+ }
165
+
166
+ /**
167
+ * Parse scan output into candidate directories and raw host facts.
168
+ * @param {string} text - scan command output.
169
+ * @returns {{ os: string, user: string, candidates: { path: string, markers: string[] }[], listening: string[], services: string[], truncated: boolean }} parsed scan.
170
+ */
171
+ export function parseScanOutput(text) {
172
+ const sections = splitSections(text)
173
+ const os = firstLine(sections.get('OS'))
174
+ const user = firstLine(sections.get('USER'))
175
+
176
+ /** @type {Map<string, { path: string, markers: string[] }>} */
177
+ const candidates = new Map()
178
+ for (const line of sections.get('FIND') ?? []) {
179
+ const found = line.trim()
180
+ if (found === '' || !found.startsWith('/')) continue
181
+ const dir = path.posix.dirname(found)
182
+ if (dir === '/' || dir === '.') continue
183
+ const marker = path.posix.basename(found)
184
+ if (marker === '') continue
185
+ let candidate = candidates.get(dir)
186
+ if (candidate === undefined) {
187
+ candidate = { path: dir, markers: [] }
188
+ candidates.set(dir, candidate)
189
+ }
190
+ if (!candidate.markers.includes(marker)) candidate.markers.push(marker)
191
+ }
192
+
193
+ /**
194
+ * Collect trimmed non-empty raw lines.
195
+ * @param {string[] | undefined} lines - section lines.
196
+ * @returns {string[]} capped lines.
197
+ */
198
+ const rawLines = (lines) => (lines ?? []).map((line) => line.trim()).filter((line) => line !== '').slice(0, MAX_RAW_LINES)
199
+
200
+ return {
201
+ os,
202
+ user,
203
+ candidates: [...candidates.values()],
204
+ listening: rawLines(sections.get('PORTS')),
205
+ services: rawLines(sections.get('UNITS')),
206
+ truncated: false,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Build a read-only inspect command for one directory.
212
+ * @param {string} remotePath - absolute POSIX directory path.
213
+ * @returns {string} shell command with sections `###PATH_EXISTS`, `###WRITABLE`,
214
+ * `###PHYSICAL`, `###SIZE_KB`, `###TOP`, `###MARKERS`, `###GIT` and `###UNITS`.
215
+ * @throws {Error & { code: 'invalid-path' }} when the path is not acceptable.
216
+ */
217
+ export function buildInspectCommand(remotePath) {
218
+ const target = validateRemotePath(remotePath)
219
+ const p = shQuote(target)
220
+ const markerList = MARKER_NAMES.map(shQuote).join(' ')
221
+
222
+ return [
223
+ `__p=${p}`,
224
+ "printf '###PATH_EXISTS\\n'",
225
+ 'if [ -d "$__p" ]; then printf \'yes\\n\'; else printf \'no\\n\'; fi',
226
+ "printf '###WRITABLE\\n'",
227
+ 'if [ -d "$__p" ] && [ -w "$__p" ]; then printf \'yes\\n\'; else printf \'no\\n\'; fi',
228
+ "printf '###PHYSICAL\\n'",
229
+ '( cd "$__p" 2>/dev/null && pwd -P ) || printf \'\\n\'',
230
+ "printf '###SIZE_KB\\n'",
231
+ 'du -sk "$__p" 2>/dev/null | awk \'{print $1}\'',
232
+ "printf '###TOP\\n'",
233
+ 'if [ -d "$__p" ]; then',
234
+ ' for __entry in "$__p"/* "$__p"/.[!.]*; do',
235
+ ' [ -e "$__entry" ] || continue',
236
+ ' if [ -L "$__entry" ]; then __type=link; __bytes=0',
237
+ ' elif [ -d "$__entry" ]; then __type=dir',
238
+ ' else __type=file',
239
+ ' fi',
240
+ ' if [ "$__type" != link ]; then __kb=$(du -sk "$__entry" 2>/dev/null | awk \'{print $1}\'); __bytes=$(( ${__kb:-0} * 1024 )); fi',
241
+ ' printf \'%s\\t%s\\t%s\\n\' "$__bytes" "$__type" "${__entry##*/}"',
242
+ ' done | head -n 80',
243
+ 'fi',
244
+ "printf '###MARKERS\\n'",
245
+ `for __marker in ${markerList}; do [ -e "$__p/$__marker" ] && printf '%s\\n' "$__marker"; done`,
246
+ 'for __file in "$__p"/*.jar "$__p"/*.war; do [ -e "$__file" ] && printf \'%s\\n\' "${__file##*/}"; done',
247
+ "printf '###GIT\\n'",
248
+ 'if [ -d "$__p/.git" ]; then',
249
+ ' printf \'remote=%s\\n\' "$(git -C "$__p" remote get-url origin 2>/dev/null)"',
250
+ ' printf \'branch=%s\\n\' "$(git -C "$__p" rev-parse --abbrev-ref HEAD 2>/dev/null)"',
251
+ ' printf \'commit=%s\\n\' "$(git -C "$__p" rev-parse HEAD 2>/dev/null)"',
252
+ ' printf \'dirty=%s\\n\' "$(git -C "$__p" status --porcelain 2>/dev/null | wc -l | tr -d \' \')"',
253
+ 'fi',
254
+ "printf '###UNITS\\n'",
255
+ 'systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | head -n 80',
256
+ "docker ps --format '{{.Names}}' 2>/dev/null | head -n 40",
257
+ ].join('\n')
258
+ }
259
+
260
+ /**
261
+ * Extract a unit-like token from one raw service line.
262
+ *
263
+ * The token is remote-controlled output, so it is accepted only when it uses
264
+ * the POSIX-safe systemd unit character set. Shell metacharacters (`;`, `$`,
265
+ * `|`, backticks, whitespace, `$IFS`-style expansions) can therefore never
266
+ * reach a suggested command, and the suggestion stays a single plain word.
267
+ *
268
+ * @param {string} line - raw line.
269
+ * @returns {string | undefined} unit identifier.
270
+ */
271
+ function unitToken(line) {
272
+ const token = line.split(/\s+/)[0] ?? ''
273
+ if (!/^[A-Za-z0-9@_.:-]+\.(?:service|socket|timer|mount|path|target|slice|scope)$/.test(token)) return undefined
274
+ return token
275
+ }
276
+
277
+ /**
278
+ * Pick a matching systemd unit for one app directory name.
279
+ * @param {string[]} units - service units.
280
+ * @param {string} appName - app directory basename.
281
+ * @returns {string | undefined} matching unit.
282
+ */
283
+ function matchingUnit(units, appName) {
284
+ if (typeof appName !== 'string' || appName === '') return undefined
285
+ return units.find((unit) => {
286
+ if (!unit.endsWith('.service')) return false
287
+ const base = unit.slice(0, -'.service'.length)
288
+ return base === appName || base.startsWith(`${appName}-`) || base.startsWith(`${appName}_`)
289
+ })
290
+ }
291
+
292
+ /**
293
+ * Build update/restart suggestions from inspection facts.
294
+ * @param {string[]} markers - marker names.
295
+ * @param {string[]} serviceUnits - service units.
296
+ * @param {string} remotePath - inspected directory.
297
+ * @returns {{ suggestedUpdateCommand?: string, suggestedRestartCommand?: string }} suggestions.
298
+ */
299
+ function suggestionsFor(markers, serviceUnits, remotePath) {
300
+ /** @type {{ suggestedUpdateCommand?: string, suggestedRestartCommand?: string }} */
301
+ const result = {}
302
+ const markerSet = new Set(markers)
303
+ const compose = [...COMPOSE_MARKERS].find((marker) => markerSet.has(marker))
304
+ if (compose !== undefined) result.suggestedUpdateCommand = 'docker compose pull && docker compose up -d --remove-orphans'
305
+ else if (markerSet.has('.git')) result.suggestedUpdateCommand = 'git pull --ff-only'
306
+
307
+ const appName = path.posix.basename(remotePath)
308
+ const unit = matchingUnit(serviceUnits, appName)
309
+ // `unitToken` guarantees a metacharacter-free unit name, so the suggestion is
310
+ // a plain word and needs no quoting; adding quotes would only make the text
311
+ // that the model copies less readable.
312
+ if (unit !== undefined) result.suggestedRestartCommand = `systemctl restart ${unit}`
313
+ return result
314
+ }
315
+
316
+ /**
317
+ * Parse inspect output.
318
+ * @param {string} text - inspect command output.
319
+ * @returns {object} parsed inspection (without the `path` field).
320
+ */
321
+ export function parseInspectOutput(text) {
322
+ const sections = splitSections(text)
323
+ const exists = firstLine(sections.get('PATH_EXISTS')).toLowerCase() === 'yes'
324
+ const writable = firstLine(sections.get('WRITABLE')).toLowerCase() === 'yes'
325
+ const physicalPath = firstLine(sections.get('PHYSICAL'))
326
+ const rawSize = Number.parseInt(firstLine(sections.get('SIZE_KB')), 10)
327
+ const sizeKB = Number.isFinite(rawSize) && rawSize >= 0 ? rawSize : 0
328
+
329
+ const topLevel = []
330
+ for (const line of sections.get('TOP') ?? []) {
331
+ const trimmed = line.trim()
332
+ if (trimmed === '') continue
333
+ const parts = trimmed.split('\t')
334
+ if (parts.length < 3) continue
335
+ const bytes = Number.parseInt(parts[0], 10)
336
+ const type = parts[1] === 'dir' || parts[1] === 'link' ? parts[1] : 'file'
337
+ const name = parts.slice(2).join('\t')
338
+ if (name === '') continue
339
+ topLevel.push({ name, type, sizeBytes: Number.isFinite(bytes) && bytes >= 0 ? bytes : 0 })
340
+ }
341
+
342
+ const markers = []
343
+ for (const line of sections.get('MARKERS') ?? []) {
344
+ const marker = line.trim()
345
+ if (marker !== '' && !markers.includes(marker)) markers.push(marker)
346
+ }
347
+
348
+ /** @type {Record<string, string>} */
349
+ const gitFields = {}
350
+ for (const line of sections.get('GIT') ?? []) {
351
+ const match = /^(remote|branch|commit|dirty)=(.*)$/.exec(line.trim())
352
+ if (match) gitFields[match[1]] = match[2]
353
+ }
354
+ const hasGit = Object.keys(gitFields).length > 0
355
+ const git = hasGit
356
+ ? {
357
+ remote: gitFields.remote ?? '',
358
+ branch: gitFields.branch ?? '',
359
+ commit: gitFields.commit ?? '',
360
+ dirty: (Number.parseInt(gitFields.dirty ?? '0', 10) || 0) > 0,
361
+ }
362
+ : null
363
+
364
+ const serviceUnits = []
365
+ for (const line of sections.get('UNITS') ?? []) {
366
+ const trimmed = line.trim()
367
+ if (trimmed === '') continue
368
+ const unit = unitToken(trimmed)
369
+ if (unit !== undefined && !serviceUnits.includes(unit)) serviceUnits.push(unit)
370
+ }
371
+
372
+ const suggestions = suggestionsFor(markers, serviceUnits, physicalPath === '' ? '/' : physicalPath)
373
+ return {
374
+ exists,
375
+ writable,
376
+ physicalPath,
377
+ sizeKB,
378
+ topLevel,
379
+ markers,
380
+ git,
381
+ serviceUnits,
382
+ ...suggestions,
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Create the discovery service.
388
+ * @param {{ ssh: { exec: Function } }} options - ssh client.
389
+ * @returns {{ scan: Function, inspect: Function }} discovery operations.
390
+ */
391
+ export function createDiscoverService({ ssh } = {}) {
392
+ if (!ssh || typeof ssh.exec !== 'function') throw new TypeError('createDiscoverService: ssh client is required')
393
+
394
+ /**
395
+ * Scan common deployment roots for project markers.
396
+ * @param {object} record - full account record.
397
+ * @param {{ roots?: string[], maxDepth?: number, limit?: number, timeoutMs?: number }} [options] - scan options.
398
+ * @returns {Promise<object>} scan result.
399
+ */
400
+ async function scan(record, options = {}) {
401
+ const limit = clampInt(options.limit, 80, 1, 200)
402
+ const timeoutMs = clampInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
403
+ const command = buildScanCommand({
404
+ roots: options.roots,
405
+ maxDepth: options.maxDepth,
406
+ limit,
407
+ })
408
+ const result = await ssh.exec(record, command, { timeoutMs })
409
+ const parsed = parseScanOutput(result.stdout)
410
+ const candidates = parsed.candidates.slice(0, limit)
411
+ const truncated = parsed.truncated || parsed.candidates.length > limit || result.truncated === true
412
+ const output = {
413
+ os: parsed.os,
414
+ user: parsed.user,
415
+ candidates,
416
+ listening: parsed.listening,
417
+ services: parsed.services,
418
+ truncated,
419
+ }
420
+ if (typeof record.name === 'string' && record.name !== '') output.server = record.name
421
+ return output
422
+ }
423
+
424
+ /**
425
+ * Inspect one remote directory (read-only).
426
+ * @param {object} record - full account record.
427
+ * @param {{ path: string, timeoutMs?: number }} options - directory and timeout.
428
+ * @returns {Promise<object>} inspection result.
429
+ */
430
+ async function inspect(record, { path: remotePath, timeoutMs } = {}) {
431
+ const target = validateRemotePath(remotePath)
432
+ const timeout = clampInt(timeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
433
+ const command = buildInspectCommand(target)
434
+ const result = await ssh.exec(record, command, { timeoutMs: timeout })
435
+ const parsed = parseInspectOutput(result.stdout)
436
+ return { path: target, ...parsed, ...suggestionsFor(parsed.markers, parsed.serviceUnits, target) }
437
+ }
438
+
439
+ return { scan, inspect }
440
+ }