@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/upload.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-gated local-to-remote file upload.
|
|
3
|
+
*
|
|
4
|
+
* `resolve` validates the request without touching the remote host beyond
|
|
5
|
+
* nothing at all: it only stats the local file and looks up the account, so the
|
|
6
|
+
* approval gate can describe the transfer before anything happens. `execute`
|
|
7
|
+
* creates the remote directory, pushes the file over SFTP, verifies size and
|
|
8
|
+
* SHA-256 on the server and records the attempt in the update history.
|
|
9
|
+
*
|
|
10
|
+
* Like `host/update.js`, this service knows nothing about the approval
|
|
11
|
+
* pipeline; `host/gate.js` is the only place that turns a valid upload target
|
|
12
|
+
* into an approval ask.
|
|
13
|
+
*
|
|
14
|
+
* @module host/upload
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { createReadStream, statSync } from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { remotePathWarnings, shQuote, validateRemotePath } from './shell.js'
|
|
21
|
+
|
|
22
|
+
/** Default upload timeout (5 min). */
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 300_000
|
|
24
|
+
/** Minimum accepted timeout (5 s). */
|
|
25
|
+
const MIN_TIMEOUT_MS = 5_000
|
|
26
|
+
/** Maximum accepted timeout (1 h). */
|
|
27
|
+
const MAX_TIMEOUT_MS = 3_600_000
|
|
28
|
+
/** Refuse to push files larger than this (2 GiB) by accident. */
|
|
29
|
+
const MAX_LOCAL_BYTES = 2 * 1024 * 1024 * 1024
|
|
30
|
+
/** Captured output tail length. */
|
|
31
|
+
const TAIL_MAX_LENGTH = 8192
|
|
32
|
+
/** Maximum accepted local path length. */
|
|
33
|
+
const MAX_LOCAL_PATH_LENGTH = 4096
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build one tagged error.
|
|
37
|
+
* @param {string} code - machine-readable error code.
|
|
38
|
+
* @param {string} message - Chinese message.
|
|
39
|
+
* @param {unknown} [cause] - underlying failure.
|
|
40
|
+
* @returns {Error & { code: string }} tagged error.
|
|
41
|
+
*/
|
|
42
|
+
function taggedError(code, message, cause) {
|
|
43
|
+
const error = new Error(message)
|
|
44
|
+
error.code = code
|
|
45
|
+
if (cause !== undefined) error.cause = cause
|
|
46
|
+
return error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {Error} invalid-input error. */
|
|
50
|
+
function invalid(message) {
|
|
51
|
+
return taggedError('invalid-input', message)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Clamp one integer option.
|
|
56
|
+
* @param {unknown} value - raw value.
|
|
57
|
+
* @param {number} fallback - default.
|
|
58
|
+
* @param {number} min - lower bound.
|
|
59
|
+
* @param {number} max - upper bound.
|
|
60
|
+
* @returns {number} clamped integer.
|
|
61
|
+
*/
|
|
62
|
+
function clampInt(value, fallback, min, max) {
|
|
63
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
64
|
+
const numeric = Number(value)
|
|
65
|
+
if (!Number.isFinite(numeric)) return fallback
|
|
66
|
+
return Math.min(max, Math.max(min, Math.round(numeric)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {unknown} value @returns {boolean} whether the value is a plain object. */
|
|
70
|
+
function isObject(value) {
|
|
71
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read one error message without leaking internals.
|
|
76
|
+
* @param {unknown} error - thrown value.
|
|
77
|
+
* @returns {string} safe message.
|
|
78
|
+
*/
|
|
79
|
+
function errorMessage(error) {
|
|
80
|
+
try {
|
|
81
|
+
if (error && typeof error.message === 'string' && error.message !== '') return error.message
|
|
82
|
+
} catch {
|
|
83
|
+
/* fall through */
|
|
84
|
+
}
|
|
85
|
+
return ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clip one captured output tail.
|
|
90
|
+
* @param {unknown} value - raw output.
|
|
91
|
+
* @returns {string} clipped text.
|
|
92
|
+
*/
|
|
93
|
+
function clipTail(value) {
|
|
94
|
+
if (typeof value !== 'string') return ''
|
|
95
|
+
return value.length <= TAIL_MAX_LENGTH ? value : value.slice(value.length - TAIL_MAX_LENGTH)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate one absolute local file path and read its size.
|
|
100
|
+
*
|
|
101
|
+
* Error messages deliberately never echo the rejected path: `resolve()` runs
|
|
102
|
+
* before any approval prompt, so a path-echoing error would hand the model a
|
|
103
|
+
* pre-approval existence/size oracle and disclose the operator's home layout.
|
|
104
|
+
*
|
|
105
|
+
* @param {unknown} raw - candidate path.
|
|
106
|
+
* @returns {{ localPath: string, fileName: string, sizeBytes: number }} facts.
|
|
107
|
+
* @throws {Error & { code: 'invalid-input' }} when the file cannot be uploaded.
|
|
108
|
+
*/
|
|
109
|
+
function inspectLocalFile(raw) {
|
|
110
|
+
if (typeof raw !== 'string') throw invalid('本地文件路径无效')
|
|
111
|
+
const text = raw.trim()
|
|
112
|
+
if (text === '') throw invalid('本地文件路径不能为空')
|
|
113
|
+
if (text.length > MAX_LOCAL_PATH_LENGTH) throw invalid(`本地文件路径不能超过 ${MAX_LOCAL_PATH_LENGTH} 个字符`)
|
|
114
|
+
if (!path.isAbsolute(text)) throw invalid('本地文件路径必须是绝对路径')
|
|
115
|
+
let stats
|
|
116
|
+
try {
|
|
117
|
+
stats = statSync(text)
|
|
118
|
+
} catch {
|
|
119
|
+
throw invalid('本地文件不存在或无法读取')
|
|
120
|
+
}
|
|
121
|
+
if (!stats.isFile()) throw invalid('本地路径不是普通文件')
|
|
122
|
+
if (stats.size <= 0) throw invalid('本地文件为空,没有可上传的内容')
|
|
123
|
+
if (stats.size > MAX_LOCAL_BYTES) throw invalid('本地文件超过 2 GiB,请改用其他分发方式')
|
|
124
|
+
return { localPath: text, fileName: path.basename(text), sizeBytes: stats.size }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute the SHA-256 of one local file.
|
|
129
|
+
* @param {string} localPath - absolute file path.
|
|
130
|
+
* @returns {Promise<string>} lowercase hex digest.
|
|
131
|
+
*/
|
|
132
|
+
async function hashLocalFile(localPath) {
|
|
133
|
+
return await new Promise((resolve, reject) => {
|
|
134
|
+
const hash = createHash('sha256')
|
|
135
|
+
const stream = createReadStream(localPath)
|
|
136
|
+
stream.on('error', (error) => reject(taggedError('upload', '无法读取本地文件', error)))
|
|
137
|
+
stream.on('data', (chunk) => hash.update(chunk))
|
|
138
|
+
stream.on('end', () => resolve(hash.digest('hex')))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create the upload service.
|
|
144
|
+
*
|
|
145
|
+
* @param {{ ssh: object, accounts: object, resources?: object, history: object, now?: () => number }} options - services.
|
|
146
|
+
* @returns {{
|
|
147
|
+
* resolve: (args: object) => object,
|
|
148
|
+
* execute: (target: object, options?: { agentId?: string }) => Promise<object>,
|
|
149
|
+
* }} upload service.
|
|
150
|
+
*/
|
|
151
|
+
export function createUploadService({ ssh, accounts, resources, history, now = () => Date.now() } = {}) {
|
|
152
|
+
if (!ssh || typeof ssh.upload !== 'function') throw new TypeError('createUploadService: ssh client with upload() is required')
|
|
153
|
+
if (!accounts || typeof accounts.find !== 'function' || typeof accounts.getSecretRecord !== 'function') {
|
|
154
|
+
throw new TypeError('createUploadService: accounts store is required')
|
|
155
|
+
}
|
|
156
|
+
if (!history || typeof history.append !== 'function') throw new TypeError('createUploadService: history store is required')
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve one account by id or name.
|
|
160
|
+
* @param {unknown} query - account id or name.
|
|
161
|
+
* @returns {{ view: object, record: object }} account view and secret record.
|
|
162
|
+
*/
|
|
163
|
+
function resolveAccount(query) {
|
|
164
|
+
const text = typeof query === 'string' ? query.trim() : ''
|
|
165
|
+
if (text === '') throw invalid('缺少服务器参数:请提供账户 id 或名称')
|
|
166
|
+
const view = accounts.find(text)
|
|
167
|
+
if (!view) throw invalid(`未找到账户 "${text}"`)
|
|
168
|
+
const record = accounts.getSecretRecord(view.id)
|
|
169
|
+
if (!record) throw invalid(`无法读取账户 "${view.name}" 的连接凭据`)
|
|
170
|
+
return { view, record }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate an upload request without touching the remote host.
|
|
175
|
+
* @param {object} args - raw tool arguments.
|
|
176
|
+
* @returns {object} upload target.
|
|
177
|
+
* @throws {Error & { code: string }} when the request is not executable.
|
|
178
|
+
*/
|
|
179
|
+
function resolve(args) {
|
|
180
|
+
if (!isObject(args)) throw invalid('上传参数无效')
|
|
181
|
+
const { view, record } = resolveAccount(args.server)
|
|
182
|
+
const local = inspectLocalFile(args.local_path)
|
|
183
|
+
const remotePath = validateRemotePath(args.remote_path)
|
|
184
|
+
const remoteDir = path.posix.dirname(remotePath)
|
|
185
|
+
if (remotePath === remoteDir) throw invalid('远程路径必须是具体文件,不能是根目录')
|
|
186
|
+
|
|
187
|
+
const timeoutMs = clampInt(args.timeout_seconds, DEFAULT_TIMEOUT_MS / 1000, MIN_TIMEOUT_MS / 1000, MAX_TIMEOUT_MS / 1000) * 1000
|
|
188
|
+
|
|
189
|
+
/** @type {object | undefined} */
|
|
190
|
+
let resource
|
|
191
|
+
const resourceQuery = typeof args.resource === 'string' ? args.resource.trim() : ''
|
|
192
|
+
if (resourceQuery !== '' && resources && typeof resources.find === 'function') {
|
|
193
|
+
const found = resources.find(resourceQuery)
|
|
194
|
+
if (!found) throw invalid(`未找到资源 "${resourceQuery}"`)
|
|
195
|
+
if (found.serverId !== view.id) throw invalid(`资源 "${found.name}" 属于另一台服务器,不能用于 ${view.name}`)
|
|
196
|
+
resource = found
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const warnings = remotePathWarnings(remotePath)
|
|
200
|
+
if (resource && typeof resource.appDir === 'string' && resource.appDir !== '') {
|
|
201
|
+
for (const warning of remotePathWarnings(remotePath, { appDir: resource.appDir })) {
|
|
202
|
+
if (!warnings.includes(warning)) warnings.push(warning)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
serverId: view.id,
|
|
208
|
+
serverName: view.name,
|
|
209
|
+
account: record,
|
|
210
|
+
resourceId: resource ? resource.id : '',
|
|
211
|
+
resourceName: resource ? resource.name : '',
|
|
212
|
+
localPath: local.localPath,
|
|
213
|
+
fileName: local.fileName,
|
|
214
|
+
sizeBytes: local.sizeBytes,
|
|
215
|
+
remotePath,
|
|
216
|
+
remoteDir,
|
|
217
|
+
timeoutMs,
|
|
218
|
+
warnings,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Run one remote command as a history step.
|
|
224
|
+
* @param {object} target - upload target.
|
|
225
|
+
* @param {{ id: string, title: string, command: string }} definition - step definition.
|
|
226
|
+
* @returns {Promise<object>} history step.
|
|
227
|
+
*/
|
|
228
|
+
async function runCommandStep(target, definition) {
|
|
229
|
+
const startedAt = now()
|
|
230
|
+
try {
|
|
231
|
+
const result = await ssh.exec(target.account, definition.command, { timeoutMs: target.timeoutMs })
|
|
232
|
+
const ok = result.exitCode === 0
|
|
233
|
+
return {
|
|
234
|
+
id: definition.id,
|
|
235
|
+
title: definition.title,
|
|
236
|
+
status: ok ? 'success' : 'failed',
|
|
237
|
+
command: definition.command,
|
|
238
|
+
exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
|
|
239
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
240
|
+
stdoutTail: clipTail(result.stdout),
|
|
241
|
+
stderrTail: clipTail(result.stderr),
|
|
242
|
+
truncated: result.truncated === true,
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return {
|
|
246
|
+
id: definition.id,
|
|
247
|
+
title: definition.title,
|
|
248
|
+
status: 'failed',
|
|
249
|
+
command: definition.command,
|
|
250
|
+
exitCode: null,
|
|
251
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
252
|
+
stdoutTail: '',
|
|
253
|
+
stderrTail: errorMessage(error),
|
|
254
|
+
truncated: false,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Execute one resolved upload: mkdir, SFTP put, verify, record history.
|
|
261
|
+
* @param {object} target - value returned by `resolve`.
|
|
262
|
+
* @param {{ agentId?: string }} [options] - execution options.
|
|
263
|
+
* @returns {Promise<object>} stored history entry.
|
|
264
|
+
*/
|
|
265
|
+
async function execute(target, options = {}) {
|
|
266
|
+
if (!isObject(target) || typeof target.remotePath !== 'string' || target.remotePath === '') {
|
|
267
|
+
throw invalid('上传目标无效,请重新调用 ssh_helper_upload')
|
|
268
|
+
}
|
|
269
|
+
void options
|
|
270
|
+
const startedAt = now()
|
|
271
|
+
/** @type {object[]} */
|
|
272
|
+
const steps = []
|
|
273
|
+
/** @type {string} */
|
|
274
|
+
let message = ''
|
|
275
|
+
/** @type {string} */
|
|
276
|
+
let sha256 = ''
|
|
277
|
+
/** @type {number} */
|
|
278
|
+
let bytes = target.sizeBytes
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Persist the attempt and return its history entry.
|
|
282
|
+
* @param {string} status - final status.
|
|
283
|
+
* @param {string} note - final message.
|
|
284
|
+
* @returns {Promise<object>} stored entry.
|
|
285
|
+
*/
|
|
286
|
+
const finish = async (status, note) => {
|
|
287
|
+
if (typeof note === 'string' && note !== '') message = note
|
|
288
|
+
const finishedAt = now()
|
|
289
|
+
return await history.append({
|
|
290
|
+
action: 'upload',
|
|
291
|
+
resourceId: target.resourceId || '',
|
|
292
|
+
resourceName: target.resourceName || target.fileName,
|
|
293
|
+
serverId: target.serverId,
|
|
294
|
+
serverName: target.serverName,
|
|
295
|
+
appDir: target.remoteDir,
|
|
296
|
+
backupPath: '',
|
|
297
|
+
status,
|
|
298
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
299
|
+
finishedAt: new Date(finishedAt).toISOString(),
|
|
300
|
+
durationMs: Math.max(0, finishedAt - startedAt),
|
|
301
|
+
steps,
|
|
302
|
+
...(message === '' ? {} : { message }),
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 1. Make sure the destination directory exists.
|
|
307
|
+
const mkdirStep = await runCommandStep(target, {
|
|
308
|
+
id: 'mkdir',
|
|
309
|
+
title: 'Create the remote directory',
|
|
310
|
+
command: `mkdir -p ${shQuote(target.remoteDir)}`,
|
|
311
|
+
})
|
|
312
|
+
steps.push(mkdirStep)
|
|
313
|
+
if (mkdirStep.status !== 'success') return await finish('failed', '无法创建远端目录,未上传任何文件。')
|
|
314
|
+
|
|
315
|
+
// 2. Hash locally so the remote copy can be compared byte for byte.
|
|
316
|
+
try {
|
|
317
|
+
sha256 = await hashLocalFile(target.localPath)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
steps.push({
|
|
320
|
+
id: 'hash',
|
|
321
|
+
title: 'Hash the local file',
|
|
322
|
+
status: 'failed',
|
|
323
|
+
exitCode: null,
|
|
324
|
+
durationMs: 0,
|
|
325
|
+
stdoutTail: '',
|
|
326
|
+
stderrTail: errorMessage(error),
|
|
327
|
+
truncated: false,
|
|
328
|
+
})
|
|
329
|
+
return await finish('failed', '读取本地文件失败,未执行上传。')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 3. Push the file over SFTP.
|
|
333
|
+
const uploadStartedAt = now()
|
|
334
|
+
try {
|
|
335
|
+
const uploaded = await ssh.upload(target.account, {
|
|
336
|
+
localPath: target.localPath,
|
|
337
|
+
remotePath: target.remotePath,
|
|
338
|
+
timeoutMs: target.timeoutMs,
|
|
339
|
+
})
|
|
340
|
+
if (typeof uploaded.bytes === 'number' && uploaded.bytes > 0) bytes = uploaded.bytes
|
|
341
|
+
steps.push({
|
|
342
|
+
id: 'sftp',
|
|
343
|
+
title: 'Upload the file over SFTP',
|
|
344
|
+
status: 'success',
|
|
345
|
+
command: `sftp put ${target.localPath} -> ${target.remotePath}`,
|
|
346
|
+
exitCode: 0,
|
|
347
|
+
durationMs: Math.max(0, now() - uploadStartedAt),
|
|
348
|
+
stdoutTail: `${bytes} bytes in ${Math.max(0, now() - uploadStartedAt)} ms`,
|
|
349
|
+
stderrTail: '',
|
|
350
|
+
truncated: false,
|
|
351
|
+
})
|
|
352
|
+
} catch (error) {
|
|
353
|
+
steps.push({
|
|
354
|
+
id: 'sftp',
|
|
355
|
+
title: 'Upload the file over SFTP',
|
|
356
|
+
status: 'failed',
|
|
357
|
+
command: `sftp put ${target.localPath} -> ${target.remotePath}`,
|
|
358
|
+
exitCode: null,
|
|
359
|
+
durationMs: Math.max(0, now() - uploadStartedAt),
|
|
360
|
+
stdoutTail: '',
|
|
361
|
+
stderrTail: errorMessage(error),
|
|
362
|
+
truncated: false,
|
|
363
|
+
})
|
|
364
|
+
return await finish('failed', 'SFTP 上传失败,远端文件可能不完整,请不要用它执行更新。')
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 4. Verify size and digest on the server. `stat` and the SHA-256 utility are
|
|
368
|
+
// spelled differently on GNU and BSD userlands, so both branches are tried.
|
|
369
|
+
const verifyStep = await runCommandStep(target, {
|
|
370
|
+
id: 'verify',
|
|
371
|
+
title: 'Verify the uploaded file',
|
|
372
|
+
command: [
|
|
373
|
+
'set -e',
|
|
374
|
+
`f=${shQuote(target.remotePath)}`,
|
|
375
|
+
'test -f "$f"',
|
|
376
|
+
'if stat -c %s "$f" >/dev/null 2>&1; then stat -c %s "$f"; else stat -f %z "$f"; fi',
|
|
377
|
+
'if command -v sha256sum >/dev/null 2>&1; then sha256sum "$f" | cut -d" " -f1; else shasum -a 256 "$f" | cut -d" " -f1; fi',
|
|
378
|
+
].join('; '),
|
|
379
|
+
})
|
|
380
|
+
steps.push(verifyStep)
|
|
381
|
+
if (verifyStep.status !== 'success') return await finish('partial', '上传完成,但远端校验失败,请不要用它执行更新。')
|
|
382
|
+
|
|
383
|
+
const lines = String(verifyStep.stdoutTail || '')
|
|
384
|
+
.split('\n')
|
|
385
|
+
.map((line) => line.trim())
|
|
386
|
+
.filter(Boolean)
|
|
387
|
+
const remoteSize = Number.parseInt(lines[0] ?? '', 10)
|
|
388
|
+
const remoteSha = (lines[1] ?? '').toLowerCase()
|
|
389
|
+
if (!Number.isFinite(remoteSize) || remoteSize !== bytes) {
|
|
390
|
+
return await finish('partial', `上传完成但大小不一致:本地 ${bytes} 字节,远端 ${Number.isFinite(remoteSize) ? remoteSize : '未知'} 字节。`)
|
|
391
|
+
}
|
|
392
|
+
if (remoteSha !== '' && remoteSha !== sha256) {
|
|
393
|
+
return await finish('partial', '上传完成但 SHA-256 不一致,文件可能在传输中损坏。')
|
|
394
|
+
}
|
|
395
|
+
return await finish('success', '上传完成并通过校验。')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { resolve, execute }
|
|
399
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="SSH Helper">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="sshHelperBg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2b7cd3" />
|
|
5
|
+
<stop offset="1" stop-color="#12325c" />
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="sshHelperShield" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#8fd6a8" />
|
|
9
|
+
<stop offset="1" stop-color="#2e9e5b" />
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect x="5" y="5" width="54" height="54" rx="14" fill="url(#sshHelperBg)" />
|
|
13
|
+
<rect x="15" y="14" width="28" height="8" rx="2.5" fill="#e8f1fb" />
|
|
14
|
+
<rect x="15" y="26" width="28" height="8" rx="2.5" fill="#bcd6f2" />
|
|
15
|
+
<circle cx="21" cy="18" r="1.6" fill="#2b7cd3" />
|
|
16
|
+
<circle cx="21" cy="30" r="1.6" fill="#2b7cd3" />
|
|
17
|
+
<path d="M47 21l10 4v7c0 7-4.4 11.4-10 13-5.6-1.6-10-6-10-13v-7z" fill="url(#sshHelperShield)" stroke="#0e2a4a" stroke-width="1.5" />
|
|
18
|
+
<path d="M42.5 31.5l3.2 3.2 6.3-6.4" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
|
19
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ssh-helper host entry.
|
|
3
|
+
*
|
|
4
|
+
* Creates the account/resource/history stores, the SSH and discovery clients,
|
|
5
|
+
* the update and upload services and the approval gate, then mounts the HTTP API,
|
|
6
|
+
* the seven agent tools, the `## SSH Helper` system-prompt section and the
|
|
7
|
+
* `tools/pre-execute` gate inside one `ctx.effect`, so disposing the plugin
|
|
8
|
+
* fiber releases every registration.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-ssh-helper
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createAccountStore, resolveDataDir } from './host/accounts.js'
|
|
14
|
+
import { registerApiRoutes } from './host/api.js'
|
|
15
|
+
import { createDiscoverService } from './host/discover.js'
|
|
16
|
+
import { createUpdateGate } from './host/gate.js'
|
|
17
|
+
import { createHistoryStore } from './host/history.js'
|
|
18
|
+
import { registerPromptSection } from './host/prompt.js'
|
|
19
|
+
import { createResourceStore } from './host/resources.js'
|
|
20
|
+
import { createSshClient } from './host/ssh.js'
|
|
21
|
+
import { registerTools } from './host/tools.js'
|
|
22
|
+
import { createUpdateService } from './host/update.js'
|
|
23
|
+
import { createUploadService } from './host/upload.js'
|
|
24
|
+
|
|
25
|
+
/** Services that must exist before this plugin mounts. */
|
|
26
|
+
export const inject = ['tools', 'systemPrompt', 'webServer', 'connection']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Log one warning without ever throwing.
|
|
30
|
+
* @param {object} ctx - plugin context.
|
|
31
|
+
* @param {string} message - warning text (never contains credentials).
|
|
32
|
+
*/
|
|
33
|
+
function warn(ctx, message) {
|
|
34
|
+
try {
|
|
35
|
+
const logger = ctx && ctx.logger
|
|
36
|
+
if (logger && typeof logger.warn === 'function') logger.warn(`dsh-ssh-helper: ${message}`)
|
|
37
|
+
} catch {
|
|
38
|
+
/* logging is best effort */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register one resource, keeping the plugin tree alive if it fails.
|
|
44
|
+
* @param {object} ctx - plugin context.
|
|
45
|
+
* @param {string} label - resource label for warnings.
|
|
46
|
+
* @param {() => Function | Function[]} register - registration callback.
|
|
47
|
+
* @param {Function[]} collected - disposer accumulator.
|
|
48
|
+
*/
|
|
49
|
+
function safelyRegister(ctx, label, register, collected) {
|
|
50
|
+
try {
|
|
51
|
+
const registered = register()
|
|
52
|
+
const queue = Array.isArray(registered) ? registered : [registered]
|
|
53
|
+
for (const disposer of queue) {
|
|
54
|
+
if (typeof disposer === 'function') collected.push(disposer)
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error && typeof error.message === 'string' ? error.message : String(error)
|
|
58
|
+
warn(ctx, `${label} registration failed: ${message}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load one store eagerly, reporting a failure as a warning.
|
|
64
|
+
* @param {object} ctx - plugin context.
|
|
65
|
+
* @param {string} label - store label.
|
|
66
|
+
* @param {{ load: Function }} store - store instance.
|
|
67
|
+
*/
|
|
68
|
+
function loadStore(ctx, label, store) {
|
|
69
|
+
try {
|
|
70
|
+
store.load()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message = error && typeof error.message === 'string' ? error.message : String(error)
|
|
73
|
+
warn(ctx, `failed to load ${label}: ${message}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mount the plugin.
|
|
79
|
+
* @param {object} ctx - host plugin context.
|
|
80
|
+
* @param {object} [_config] - loader config (unused; the bundle passes `{}`).
|
|
81
|
+
*/
|
|
82
|
+
export function apply(ctx, _config) {
|
|
83
|
+
const dir = resolveDataDir()
|
|
84
|
+
const accounts = createAccountStore({ dir })
|
|
85
|
+
const resources = createResourceStore({ dir, accounts })
|
|
86
|
+
const history = createHistoryStore({ dir })
|
|
87
|
+
const ssh = createSshClient()
|
|
88
|
+
const discover = createDiscoverService({ ssh })
|
|
89
|
+
const update = createUpdateService({ ssh, resources, history })
|
|
90
|
+
const upload = createUploadService({ ssh, accounts, resources, history })
|
|
91
|
+
const gate = createUpdateGate({ update, upload, resources })
|
|
92
|
+
|
|
93
|
+
loadStore(ctx, 'accounts', accounts)
|
|
94
|
+
loadStore(ctx, 'resources', resources)
|
|
95
|
+
loadStore(ctx, 'history', history)
|
|
96
|
+
|
|
97
|
+
const mount = () => {
|
|
98
|
+
/** @type {Function[]} */
|
|
99
|
+
const disposers = []
|
|
100
|
+
// The approval gate must mount before any remote-write tool. If it does not,
|
|
101
|
+
// the DSH `tools/pre-execute` waterfall would default to `allow`, turning
|
|
102
|
+
// apply/rollback/upload into unguarded writes, so writes are disabled
|
|
103
|
+
// instead of proceeding without a gate.
|
|
104
|
+
let gateReady = false
|
|
105
|
+
safelyRegister(
|
|
106
|
+
ctx,
|
|
107
|
+
'approval gate',
|
|
108
|
+
() => {
|
|
109
|
+
const dispose = ctx.on('tools/pre-execute', gate)
|
|
110
|
+
gateReady = true
|
|
111
|
+
return dispose
|
|
112
|
+
},
|
|
113
|
+
disposers,
|
|
114
|
+
)
|
|
115
|
+
safelyRegister(ctx, 'HTTP API', () => registerApiRoutes(ctx, { accounts, resources, history, ssh }), disposers)
|
|
116
|
+
safelyRegister(
|
|
117
|
+
ctx,
|
|
118
|
+
'agent tools',
|
|
119
|
+
() =>
|
|
120
|
+
registerTools(ctx, { accounts, resources, history, ssh, discover, update, upload, allowWrites: gateReady }),
|
|
121
|
+
disposers,
|
|
122
|
+
)
|
|
123
|
+
safelyRegister(ctx, 'system prompt section', () => registerPromptSection(ctx, { accounts, resources }), disposers)
|
|
124
|
+
return () => {
|
|
125
|
+
for (const dispose of disposers.splice(0).reverse()) {
|
|
126
|
+
try {
|
|
127
|
+
dispose()
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const message = error && typeof error.message === 'string' ? error.message : String(error)
|
|
130
|
+
warn(ctx, `dispose failed: ${message}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof ctx.effect === 'function') {
|
|
137
|
+
ctx.effect(mount, 'dsh-ssh-helper: accounts, resources, history, tools, prompt, API and approval gate')
|
|
138
|
+
} else {
|
|
139
|
+
mount()
|
|
140
|
+
}
|
|
141
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hunan36/dsh-ssh-helper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SSH accounts, a deployment-directory catalogue, and approval-gated remote updates, rollbacks and uploads for DeepSeek Harness.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"deepseek",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"plugin",
|
|
10
|
+
"dsh-plugin",
|
|
11
|
+
"ssh",
|
|
12
|
+
"sftp",
|
|
13
|
+
"deploy",
|
|
14
|
+
"deployment",
|
|
15
|
+
"update",
|
|
16
|
+
"rollback"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "hunan36",
|
|
20
|
+
"homepage": "https://github.com/hunan36/dsh-ssh-helper#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/hunan36/dsh-ssh-helper.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/hunan36/dsh-ssh-helper/issues"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "./index.js",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./index.js",
|
|
32
|
+
"./client": "./client.js",
|
|
33
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
34
|
+
"./package.json": "./package.json",
|
|
35
|
+
"./locale/*.json": "./locale/*.json",
|
|
36
|
+
"./icon": "./icon.svg"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"index.js",
|
|
40
|
+
"host/**/*.js",
|
|
41
|
+
"client.js",
|
|
42
|
+
"cordis.patch.yml",
|
|
43
|
+
"locale/*.json",
|
|
44
|
+
"icon.svg",
|
|
45
|
+
"docs/*.png",
|
|
46
|
+
"README.md",
|
|
47
|
+
"README.zh.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"platform": "web",
|
|
56
|
+
"immediately": true,
|
|
57
|
+
"inject": [
|
|
58
|
+
"@deepseek-ai/dsh-client-locale",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"ssh2": "^1.17.0"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"check": "node scripts/check-syntax.mjs",
|
|
68
|
+
"test": "node scripts/check-syntax.mjs && node --test test/*.test.mjs",
|
|
69
|
+
"smoke": "node scripts/smoke-host.mjs"
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public"
|
|
76
|
+
}
|
|
77
|
+
}
|