@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.
- package/LICENSE +21 -0
- package/README.md +330 -2
- package/README.zh.md +331 -0
- package/client.js +2786 -0
- package/cordis.patch.yml +13 -0
- package/docs/screenshot-accounts.png +0 -0
- package/docs/screenshot-activity.png +0 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-resource-detail.png +0 -0
- package/host/accounts.js +688 -0
- package/host/api.js +440 -0
- package/host/atomic.js +160 -0
- package/host/discover.js +440 -0
- package/host/gate.js +264 -0
- package/host/history.js +276 -0
- package/host/prompt.js +103 -0
- package/host/resources.js +604 -0
- package/host/shell.js +194 -0
- package/host/ssh.js +460 -0
- package/host/tools.js +1086 -0
- package/host/update.js +992 -0
- package/host/upload.js +399 -0
- package/icon.svg +19 -0
- package/index.js +141 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
package/host/discover.js
ADDED
|
@@ -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
|
+
}
|