@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/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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "SSH Helper",
4
+ "description": "Manage SSH accounts, catalog deployment directories, and run approval-gated remote updates and rollbacks."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "SSH 助手",
4
+ "description": "管理 SSH 账户,采集部署服务目录,并在人工确认后执行远程更新与回滚。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@hunan36/dsh-ssh-helper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }