@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/gate.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-native approval gate for remote updates.
|
|
3
|
+
*
|
|
4
|
+
* The gate is registered on `tools/pre-execute`. It is the only place that
|
|
5
|
+
* turns a valid `ssh_helper_update_apply` plan (or a resolvable
|
|
6
|
+
* `ssh_helper_update_rollback` backup) into `{ kind: 'ask' }`; every other tool
|
|
7
|
+
* and every invalid or expired request falls through with `next()` so the tool
|
|
8
|
+
* body can return its own guidance error. There is deliberately no `confirm`,
|
|
9
|
+
* `force` or `skip_approval` parameter.
|
|
10
|
+
*
|
|
11
|
+
* @module host/gate
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { remotePathWarnings, shQuote } from './shell.js'
|
|
15
|
+
|
|
16
|
+
/** Tool name for the approved update. */
|
|
17
|
+
const APPLY_TOOL = 'ssh_helper_update_apply'
|
|
18
|
+
/** Tool name for the approved rollback. */
|
|
19
|
+
const ROLLBACK_TOOL = 'ssh_helper_update_rollback'
|
|
20
|
+
/** Tool name for the approved upload. */
|
|
21
|
+
const UPLOAD_TOOL = 'ssh_helper_upload'
|
|
22
|
+
/** Maximum command length shown in an approval reason. */
|
|
23
|
+
const REASON_COMMAND_LIMIT = 300
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Truncate one command for the approval prompt.
|
|
27
|
+
* @param {unknown} value - raw command.
|
|
28
|
+
* @returns {string} truncated text.
|
|
29
|
+
*/
|
|
30
|
+
function cut(value) {
|
|
31
|
+
if (typeof value !== 'string') return ''
|
|
32
|
+
return value.length <= REASON_COMMAND_LIMIT ? value : `${value.slice(0, REASON_COMMAND_LIMIT)}…`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Make one text value safe for a single-line approval reason.
|
|
37
|
+
*
|
|
38
|
+
* The approval prompt is a security boundary: every interpolated value must be
|
|
39
|
+
* flattened onto one line, otherwise a resource name or path carrying `\n`
|
|
40
|
+
* could forge prompt chrome such as a second `Backup: disabled.` line. Control
|
|
41
|
+
* characters and bidirectional overrides are dropped as well, so a path cannot
|
|
42
|
+
* render reversed in the prompt the operator reads.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} value - raw text.
|
|
45
|
+
* @returns {string} one-line text.
|
|
46
|
+
*/
|
|
47
|
+
function line(value) {
|
|
48
|
+
if (typeof value !== 'string') return ''
|
|
49
|
+
return value
|
|
50
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
51
|
+
.replace(/[\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, '')
|
|
52
|
+
.replace(/\s+/g, ' ')
|
|
53
|
+
.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Delimiter that fences a verbatim multi-line command inside a prompt. */
|
|
57
|
+
const FENCE = '----- command (verbatim) -----'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render a command block the operator can read as data, never as prompt chrome.
|
|
61
|
+
*
|
|
62
|
+
* Multi-line shell scripts are legitimate, so they are not rejected; instead
|
|
63
|
+
* they are fenced so a reader cannot mistake a line of the script for one of
|
|
64
|
+
* the prompt's own labels. The block is truncated to keep the prompt short.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} value - raw command.
|
|
67
|
+
* @returns {string[]} three lines: opening fence, body, closing fence.
|
|
68
|
+
*/
|
|
69
|
+
function fence(value) {
|
|
70
|
+
const text = typeof value === 'string' ? value.replace(/[\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, '') : ''
|
|
71
|
+
return [FENCE, cut(text), FENCE]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the approval decision for one valid apply plan.
|
|
76
|
+
* @param {object} plan - cached plan.
|
|
77
|
+
* @returns {{ kind: 'ask', reason: string, displayReason: { en: string, zh: string } }} decision.
|
|
78
|
+
*/
|
|
79
|
+
function askForApply(plan) {
|
|
80
|
+
const backup = line(plan.backup && plan.backup.enabled ? plan.backup.file : 'disabled')
|
|
81
|
+
const name = line(plan.resourceName)
|
|
82
|
+
const server = line(plan.serverName)
|
|
83
|
+
const dir = line(plan.appDir)
|
|
84
|
+
const warnings = Array.isArray(plan.warnings) ? plan.warnings.map(line).filter(Boolean) : []
|
|
85
|
+
const restart = line(plan.restartCommand)
|
|
86
|
+
const verify = line(plan.verifyCommand)
|
|
87
|
+
|
|
88
|
+
const english = [
|
|
89
|
+
`Approve remote update plan ${line(plan.id)} for "${name}" on ${server} (${dir}).`,
|
|
90
|
+
`Backup: ${backup}.`,
|
|
91
|
+
...fence(plan.updateCommand),
|
|
92
|
+
]
|
|
93
|
+
if (restart !== '') english.push(`Restart command: ${cut(restart)}`)
|
|
94
|
+
if (verify !== '') english.push(`Verify command: ${cut(verify)}`)
|
|
95
|
+
if (warnings.length > 0) english.push(`Warnings: ${warnings.join('; ')}`)
|
|
96
|
+
|
|
97
|
+
const chinese = [
|
|
98
|
+
`批准更新 "${name}":服务器 ${server},目录 ${dir}。`,
|
|
99
|
+
`备份:${backup}。`,
|
|
100
|
+
...fence(plan.updateCommand),
|
|
101
|
+
]
|
|
102
|
+
if (restart !== '') chinese.push(`重启命令:${cut(restart)}`)
|
|
103
|
+
if (verify !== '') chinese.push(`校验命令:${cut(verify)}`)
|
|
104
|
+
if (warnings.length > 0) chinese.push(`警告:${warnings.join(';')}`)
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
kind: 'ask',
|
|
108
|
+
reason: english.join('\n'),
|
|
109
|
+
displayReason: { en: english.join('\n'), zh: chinese.join('\n') },
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the approval decision for one valid rollback target.
|
|
115
|
+
* @param {object} target - rollback target from `update.resolveRollbackTarget`.
|
|
116
|
+
* @returns {{ kind: 'ask', reason: string, displayReason: { en: string, zh: string } }} decision.
|
|
117
|
+
*/
|
|
118
|
+
function askForRollback(target) {
|
|
119
|
+
const resource = target.resource
|
|
120
|
+
const name = line(resource.name)
|
|
121
|
+
const server = line(resource.serverName || '')
|
|
122
|
+
const dir = line(resource.appDir)
|
|
123
|
+
const backupPath = line(target.backupPath)
|
|
124
|
+
const parent = line(target.parent)
|
|
125
|
+
const warnings = Array.isArray(remotePathWarnings(resource.appDir)) ? remotePathWarnings(resource.appDir).map(line).filter(Boolean) : []
|
|
126
|
+
const restart = line(resource.restartCommand)
|
|
127
|
+
const verify = line(resource.verifyCommand)
|
|
128
|
+
const restoreCommand = `tar -C ${shQuote(parent)} -xzf ${shQuote(backupPath)}`
|
|
129
|
+
const english = [
|
|
130
|
+
`Approve rollback of "${name}" on ${server} (${dir}).`,
|
|
131
|
+
`Restore backup: ${backupPath}.`,
|
|
132
|
+
`The current directory is moved aside before extraction.`,
|
|
133
|
+
`Restore command: ${cut(restoreCommand)}`,
|
|
134
|
+
]
|
|
135
|
+
if (restart !== '') english.push(`Restart command: ${cut(restart)}`)
|
|
136
|
+
if (verify !== '') english.push(`Verify command: ${cut(verify)}`)
|
|
137
|
+
if (warnings.length > 0) english.push(`Warnings: ${warnings.join('; ')}`)
|
|
138
|
+
|
|
139
|
+
const chinese = [
|
|
140
|
+
`批准回滚 "${name}":服务器 ${server},目录 ${dir}。`,
|
|
141
|
+
`恢复备份:${backupPath}。`,
|
|
142
|
+
`解压前会先把当前目录移动到一旁。`,
|
|
143
|
+
`恢复命令:${cut(restoreCommand)}`,
|
|
144
|
+
]
|
|
145
|
+
if (restart !== '') chinese.push(`重启命令:${cut(restart)}`)
|
|
146
|
+
if (verify !== '') chinese.push(`校验命令:${cut(verify)}`)
|
|
147
|
+
if (warnings.length > 0) chinese.push(`警告:${warnings.join(';')}`)
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
kind: 'ask',
|
|
151
|
+
reason: english.join('\n'),
|
|
152
|
+
displayReason: { en: english.join('\n'), zh: chinese.join('\n') },
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Format one byte count for a prompt line.
|
|
158
|
+
* @param {unknown} value - raw bytes.
|
|
159
|
+
* @returns {string} human readable size.
|
|
160
|
+
*/
|
|
161
|
+
function size(value) {
|
|
162
|
+
const bytes = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
163
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
|
|
164
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`
|
|
165
|
+
return `${bytes} B`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the approval decision for one valid upload target.
|
|
170
|
+
* @param {object} target - value from `upload.resolve`.
|
|
171
|
+
* @returns {{ kind: 'ask', reason: string, displayReason: { en: string, zh: string } }} decision.
|
|
172
|
+
*/
|
|
173
|
+
function askForUpload(target) {
|
|
174
|
+
const warnings = Array.isArray(target.warnings) ? target.warnings.map(line).filter(Boolean) : []
|
|
175
|
+
const fileName = line(target.fileName)
|
|
176
|
+
const serverName = line(target.serverName)
|
|
177
|
+
const remotePath = line(target.remotePath)
|
|
178
|
+
const localPath = line(target.localPath)
|
|
179
|
+
const resourceName = line(target.resourceName)
|
|
180
|
+
const english = [
|
|
181
|
+
`Approve upload of "${fileName}" (${size(target.sizeBytes)}) to ${serverName} at ${remotePath}.`,
|
|
182
|
+
`Local file: ${localPath}.`,
|
|
183
|
+
`The file is transferred over SFTP, then its size and SHA-256 are verified on the server.`,
|
|
184
|
+
]
|
|
185
|
+
if (resourceName !== '') english.push(`Deployment resource: ${resourceName}.`)
|
|
186
|
+
if (warnings.length > 0) english.push(`Warnings: ${warnings.join('; ')}`)
|
|
187
|
+
|
|
188
|
+
const chinese = [
|
|
189
|
+
`批准上传 "${fileName}"(${size(target.sizeBytes)})到 ${serverName} 的 ${remotePath}。`,
|
|
190
|
+
`本地文件:${localPath}。`,
|
|
191
|
+
`文件通过 SFTP 传输,随后在服务器上校验大小与 SHA-256。`,
|
|
192
|
+
]
|
|
193
|
+
if (resourceName !== '') chinese.push(`部署资源:${resourceName}。`)
|
|
194
|
+
if (warnings.length > 0) chinese.push(`警告:${warnings.join(';')}`)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
kind: 'ask',
|
|
198
|
+
reason: english.join('\n'),
|
|
199
|
+
displayReason: { en: english.join('\n'), zh: chinese.join('\n') },
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create the `tools/pre-execute` handler.
|
|
205
|
+
* @param {{ update: object, upload?: object, resources: object }} services - services (`update` must expose `peekPlan` and `resolveRollbackTarget`; `upload` must expose `resolve`).
|
|
206
|
+
* @returns {(exec: object, next: Function) => Promise<object>} waterfall handler.
|
|
207
|
+
*/
|
|
208
|
+
export function createUpdateGate({ update, upload, resources } = {}) {
|
|
209
|
+
if (!update || typeof update !== 'object') throw new TypeError('createUpdateGate: update service is required')
|
|
210
|
+
void resources
|
|
211
|
+
|
|
212
|
+
return async function updateGate(exec, next) {
|
|
213
|
+
try {
|
|
214
|
+
const name =
|
|
215
|
+
exec && typeof exec.name === 'string'
|
|
216
|
+
? exec.name
|
|
217
|
+
: exec && typeof exec.toolName === 'string'
|
|
218
|
+
? exec.toolName
|
|
219
|
+
: exec && exec.tool && typeof exec.tool.name === 'string'
|
|
220
|
+
? exec.tool.name
|
|
221
|
+
: ''
|
|
222
|
+
if (name !== APPLY_TOOL && name !== ROLLBACK_TOOL && name !== UPLOAD_TOOL) return next()
|
|
223
|
+
|
|
224
|
+
const args =
|
|
225
|
+
exec && typeof exec.arguments === 'object' && exec.arguments !== null && !Array.isArray(exec.arguments)
|
|
226
|
+
? exec.arguments
|
|
227
|
+
: {}
|
|
228
|
+
const agentId = exec && exec.agent && typeof exec.agent.id === 'string' ? exec.agent.id : undefined
|
|
229
|
+
|
|
230
|
+
if (name === APPLY_TOOL) {
|
|
231
|
+
const planId = typeof args.plan_id === 'string' ? args.plan_id.trim() : ''
|
|
232
|
+
if (planId === '' || typeof update.peekPlan !== 'function') return next()
|
|
233
|
+
const plan = update.peekPlan(planId, agentId)
|
|
234
|
+
if (!plan || !Array.isArray(plan.blockers) || plan.blockers.length > 0) return next()
|
|
235
|
+
return askForApply(plan)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (name === UPLOAD_TOOL) {
|
|
239
|
+
if (!upload || typeof upload.resolve !== 'function') return next()
|
|
240
|
+
let target
|
|
241
|
+
try {
|
|
242
|
+
target = upload.resolve(args)
|
|
243
|
+
} catch {
|
|
244
|
+
// An invalid request is rejected by the tool body with guidance; it is
|
|
245
|
+
// never turned into an approval prompt.
|
|
246
|
+
return next()
|
|
247
|
+
}
|
|
248
|
+
return askForUpload(target)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof update.resolveRollbackTarget !== 'function') return next()
|
|
252
|
+
const resource = typeof args.resource === 'string' ? args.resource.trim() : ''
|
|
253
|
+
if (resource === '') return next()
|
|
254
|
+
const historyId = typeof args.history_id === 'string' ? args.history_id.trim() : undefined
|
|
255
|
+
const target = update.resolveRollbackTarget(resource, historyId)
|
|
256
|
+
if (!target) return next()
|
|
257
|
+
if (typeof update.pinRollback !== 'function') return next()
|
|
258
|
+
update.pinRollback(target, agentId)
|
|
259
|
+
return askForRollback(target)
|
|
260
|
+
} catch {
|
|
261
|
+
return next()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
package/host/history.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent update history store.
|
|
3
|
+
*
|
|
4
|
+
* Every apply/rollback attempt is stored newest first in `history.json`, capped
|
|
5
|
+
* at 200 entries. Step output tails are clipped to 8192 characters so history
|
|
6
|
+
* stays bounded and credential shapes are masked before they are persisted, so
|
|
7
|
+
* neither `history.json` nor the browser ever shows a leaked secret. The command
|
|
8
|
+
* text is kept verbatim because it is the audit record. The file is written
|
|
9
|
+
* atomically with mode `0600`.
|
|
10
|
+
*
|
|
11
|
+
* @module host/history
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from 'node:crypto'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { createJsonFile } from './atomic.js'
|
|
17
|
+
|
|
18
|
+
/** File name inside the plugin data directory. */
|
|
19
|
+
const DATA_FILE = 'history.json'
|
|
20
|
+
/** Maximum retained history entries. */
|
|
21
|
+
const MAX_ENTRIES = 200
|
|
22
|
+
/** Maximum characters kept per captured output tail. */
|
|
23
|
+
const TAIL_MAX_LENGTH = 8192
|
|
24
|
+
/** Default list limit (all retained entries). */
|
|
25
|
+
const DEFAULT_LIST_LIMIT = MAX_ENTRIES
|
|
26
|
+
/** Accepted actions. */
|
|
27
|
+
const ACTIONS = new Set(['apply', 'rollback', 'upload'])
|
|
28
|
+
/** Accepted statuses. */
|
|
29
|
+
const STATUSES = new Set(['success', 'partial', 'failed', 'blocked'])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build one tagged error.
|
|
33
|
+
* @param {string} code - machine-readable code.
|
|
34
|
+
* @param {string} message - Chinese message.
|
|
35
|
+
* @param {unknown} [cause] - underlying failure.
|
|
36
|
+
* @returns {Error & { code: string }} tagged error.
|
|
37
|
+
*/
|
|
38
|
+
function taggedError(code, message, cause) {
|
|
39
|
+
const error = new Error(message)
|
|
40
|
+
error.code = code
|
|
41
|
+
if (cause !== undefined) error.cause = cause
|
|
42
|
+
return error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns {Error} invalid-input error. */
|
|
46
|
+
function invalid(message) {
|
|
47
|
+
return taggedError('invalid-input', message)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @returns {Error} storage error. */
|
|
51
|
+
function storageError(cause) {
|
|
52
|
+
return taggedError('storage', '无法保存更新历史', cause)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {unknown} value @returns {boolean} whether the value is a plain object. */
|
|
56
|
+
function isObject(value) {
|
|
57
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mask credential shapes in one captured output tail.
|
|
62
|
+
*
|
|
63
|
+
* Captured stdout/stderr routinely contains secrets: `bash -x`/`set -x` echoes
|
|
64
|
+
* the command after expansion and failing CLIs echo their argv, so a token that
|
|
65
|
+
* only ever existed on the remote host can land in `history.json` and in the
|
|
66
|
+
* browser. The command text itself stays verbatim (it is the audit record) —
|
|
67
|
+
* only the captured output is scrubbed here.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} text - captured output.
|
|
70
|
+
* @returns {string} scrubbed output.
|
|
71
|
+
*/
|
|
72
|
+
function redactSecrets(text) {
|
|
73
|
+
return text
|
|
74
|
+
.replace(/\b(password|passwd|passphrase|token|secret|api[_-]?key)\b(\s*[=:]\s*)\S+/gi, (_match, name, separator) => `${name}${separator}<redacted>`)
|
|
75
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '<redacted-private-key>')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clip one captured output tail to the contract maximum (the tail, not the
|
|
80
|
+
* head), then scrub credential shapes from it.
|
|
81
|
+
* @param {unknown} value - raw output.
|
|
82
|
+
* @returns {string} clipped and scrubbed text.
|
|
83
|
+
*/
|
|
84
|
+
function clipTail(value) {
|
|
85
|
+
if (typeof value !== 'string') return ''
|
|
86
|
+
const clipped = value.length <= TAIL_MAX_LENGTH ? value : value.slice(value.length - TAIL_MAX_LENGTH)
|
|
87
|
+
return redactSecrets(clipped)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize one history step.
|
|
92
|
+
* @param {unknown} step - raw step.
|
|
93
|
+
* @param {number} index - step index for defaults.
|
|
94
|
+
* @returns {object} sanitized step.
|
|
95
|
+
*/
|
|
96
|
+
function readStep(step, index) {
|
|
97
|
+
if (!isObject(step)) throw invalid('更新步骤格式无效')
|
|
98
|
+
const id = typeof step.id === 'string' && step.id !== '' ? step.id : `step-${index + 1}`
|
|
99
|
+
const title = typeof step.title === 'string' && step.title !== '' ? step.title : id
|
|
100
|
+
const status = typeof step.status === 'string' && step.status !== '' ? step.status : 'failed'
|
|
101
|
+
const record = {
|
|
102
|
+
id,
|
|
103
|
+
title,
|
|
104
|
+
status,
|
|
105
|
+
exitCode: Number.isInteger(step.exitCode) ? step.exitCode : null,
|
|
106
|
+
durationMs: typeof step.durationMs === 'number' && Number.isFinite(step.durationMs) ? Math.max(0, step.durationMs) : 0,
|
|
107
|
+
stdoutTail: clipTail(step.stdoutTail),
|
|
108
|
+
stderrTail: clipTail(step.stderrTail),
|
|
109
|
+
truncated: step.truncated === true,
|
|
110
|
+
}
|
|
111
|
+
if (typeof step.command === 'string' && step.command !== '') record.command = step.command
|
|
112
|
+
return record
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Normalize one history entry.
|
|
117
|
+
* @param {unknown} entry - raw entry.
|
|
118
|
+
* @returns {object} sanitized entry.
|
|
119
|
+
*/
|
|
120
|
+
function readEntry(entry) {
|
|
121
|
+
if (!isObject(entry)) throw invalid('更新历史格式无效')
|
|
122
|
+
const action = typeof entry.action === 'string' ? entry.action : ''
|
|
123
|
+
if (!ACTIONS.has(action)) throw invalid('更新历史动作无效')
|
|
124
|
+
const status = typeof entry.status === 'string' ? entry.status : ''
|
|
125
|
+
if (!STATUSES.has(status)) throw invalid('更新历史状态无效')
|
|
126
|
+
const steps = Array.isArray(entry.steps) ? entry.steps.map((step, index) => readStep(step, index)) : []
|
|
127
|
+
const record = {
|
|
128
|
+
id: typeof entry.id === 'string' && entry.id !== '' ? entry.id : randomUUID(),
|
|
129
|
+
action,
|
|
130
|
+
resourceId: typeof entry.resourceId === 'string' ? entry.resourceId : '',
|
|
131
|
+
resourceName: typeof entry.resourceName === 'string' ? entry.resourceName : '',
|
|
132
|
+
serverId: typeof entry.serverId === 'string' ? entry.serverId : '',
|
|
133
|
+
serverName: typeof entry.serverName === 'string' ? entry.serverName : '',
|
|
134
|
+
appDir: typeof entry.appDir === 'string' ? entry.appDir : '',
|
|
135
|
+
backupPath: typeof entry.backupPath === 'string' ? entry.backupPath : '',
|
|
136
|
+
status,
|
|
137
|
+
startedAt: typeof entry.startedAt === 'string' ? entry.startedAt : '',
|
|
138
|
+
finishedAt: typeof entry.finishedAt === 'string' ? entry.finishedAt : '',
|
|
139
|
+
durationMs: typeof entry.durationMs === 'number' && Number.isFinite(entry.durationMs) ? Math.max(0, entry.durationMs) : 0,
|
|
140
|
+
steps,
|
|
141
|
+
}
|
|
142
|
+
if (typeof entry.message === 'string' && entry.message !== '') record.message = entry.message
|
|
143
|
+
return record
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a parsed entry can be loaded.
|
|
148
|
+
* @param {unknown} entry - parsed entry.
|
|
149
|
+
* @returns {boolean} usability.
|
|
150
|
+
*/
|
|
151
|
+
function isUsable(entry) {
|
|
152
|
+
if (!isObject(entry)) return false
|
|
153
|
+
if (typeof entry.id !== 'string' || entry.id === '') return false
|
|
154
|
+
if (typeof entry.action !== 'string' || !ACTIONS.has(entry.action)) return false
|
|
155
|
+
if (typeof entry.status !== 'string' || !STATUSES.has(entry.status)) return false
|
|
156
|
+
if (!Array.isArray(entry.steps)) return false
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Deep-copy one entry so callers cannot mutate stored state.
|
|
162
|
+
* @param {object} entry - stored entry.
|
|
163
|
+
* @returns {object} copy.
|
|
164
|
+
*/
|
|
165
|
+
function copyEntry(entry) {
|
|
166
|
+
return JSON.parse(JSON.stringify(entry))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create the history store.
|
|
171
|
+
* @param {{ dir: string }} options - plugin data directory.
|
|
172
|
+
* @returns {{
|
|
173
|
+
* load: () => void,
|
|
174
|
+
* append: (entry: object) => Promise<object>,
|
|
175
|
+
* list: (options?: { resourceId?: string, limit?: number }) => object[],
|
|
176
|
+
* get: (id: string) => object | undefined,
|
|
177
|
+
* }} store API.
|
|
178
|
+
*/
|
|
179
|
+
export function createHistoryStore({ dir } = {}) {
|
|
180
|
+
if (typeof dir !== 'string' || dir.trim() === '') throw new TypeError('createHistoryStore: dir is required')
|
|
181
|
+
const resolvedDir = path.resolve(dir)
|
|
182
|
+
const file = createJsonFile({ dir: resolvedDir, filename: DATA_FILE })
|
|
183
|
+
|
|
184
|
+
/** @type {object[]} entries, newest first. */
|
|
185
|
+
let entries = []
|
|
186
|
+
let loaded = false
|
|
187
|
+
/** @type {Promise<unknown>} tail of the serialized mutation chain. */
|
|
188
|
+
let mutationTail = Promise.resolve()
|
|
189
|
+
|
|
190
|
+
/** Read the data file once; never throws. */
|
|
191
|
+
function load() {
|
|
192
|
+
if (loaded) return
|
|
193
|
+
loaded = true
|
|
194
|
+
const parsed = file.read()
|
|
195
|
+
if (!Array.isArray(parsed)) {
|
|
196
|
+
entries = []
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
entries = parsed.filter(isUsable).slice(0, MAX_ENTRIES).map((entry) => {
|
|
200
|
+
try {
|
|
201
|
+
return readEntry(entry)
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
}).filter((entry) => entry !== undefined)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ensure the lazy read happened. */
|
|
209
|
+
function ensureLoaded() {
|
|
210
|
+
if (!loaded) load()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Serialize one mutation on top of earlier mutations.
|
|
215
|
+
* @template T
|
|
216
|
+
* @param {() => Promise<T>} task - mutation.
|
|
217
|
+
* @returns {Promise<T>} outcome.
|
|
218
|
+
*/
|
|
219
|
+
function enqueue(task) {
|
|
220
|
+
const run = mutationTail.then(task, task)
|
|
221
|
+
mutationTail = run.then(
|
|
222
|
+
() => undefined,
|
|
223
|
+
() => undefined,
|
|
224
|
+
)
|
|
225
|
+
return run
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Append one entry at the front and persist.
|
|
230
|
+
* @param {object} entry - history entry (id assigned when absent).
|
|
231
|
+
* @returns {Promise<object>} stored entry.
|
|
232
|
+
*/
|
|
233
|
+
function append(entry) {
|
|
234
|
+
ensureLoaded()
|
|
235
|
+
return enqueue(async () => {
|
|
236
|
+
const record = readEntry(entry)
|
|
237
|
+
const previous = entries
|
|
238
|
+
entries = [record, ...entries].slice(0, MAX_ENTRIES)
|
|
239
|
+
try {
|
|
240
|
+
await file.write(entries)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
entries = previous
|
|
243
|
+
throw storageError(error)
|
|
244
|
+
}
|
|
245
|
+
return copyEntry(record)
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* List entries newest first.
|
|
251
|
+
* @param {{ resourceId?: string, limit?: number }} [options] - filter and page size.
|
|
252
|
+
* @returns {object[]} entries.
|
|
253
|
+
*/
|
|
254
|
+
function list({ resourceId, limit } = {}) {
|
|
255
|
+
ensureLoaded()
|
|
256
|
+
const max = typeof limit === 'number' && Number.isFinite(limit) ? Math.min(MAX_ENTRIES, Math.max(1, Math.round(limit))) : DEFAULT_LIST_LIMIT
|
|
257
|
+
const filtered =
|
|
258
|
+
typeof resourceId === 'string' && resourceId !== ''
|
|
259
|
+
? entries.filter((entry) => entry.resourceId === resourceId)
|
|
260
|
+
: entries
|
|
261
|
+
return filtered.slice(0, max).map((entry) => copyEntry(entry))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {string} id - entry id.
|
|
266
|
+
* @returns {object | undefined} entry copy.
|
|
267
|
+
*/
|
|
268
|
+
function get(id) {
|
|
269
|
+
ensureLoaded()
|
|
270
|
+
if (typeof id !== 'string' || id === '') return undefined
|
|
271
|
+
const entry = entries.find((candidate) => candidate.id === id)
|
|
272
|
+
return entry === undefined ? undefined : copyEntry(entry)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { load, append, list, get }
|
|
276
|
+
}
|
package/host/prompt.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-prompt section for dsh-ssh-helper.
|
|
3
|
+
*
|
|
4
|
+
* The text is rebuilt on every prompt assembly from the live stores, so the
|
|
5
|
+
* model sees the accounts and deployment resources configured right now. It
|
|
6
|
+
* only reads public views, which never contain credentials.
|
|
7
|
+
*
|
|
8
|
+
* @module host/prompt
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Unique prompt section name. */
|
|
12
|
+
const SECTION_NAME = 'ssh-helper:resources'
|
|
13
|
+
/** Section order. */
|
|
14
|
+
const SECTION_ORDER = 45
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Make one user-controlled value safe to embed in a single Markdown line.
|
|
18
|
+
* @param {unknown} value - raw value.
|
|
19
|
+
* @returns {string} one-line text.
|
|
20
|
+
*/
|
|
21
|
+
function singleLine(value) {
|
|
22
|
+
if (typeof value !== 'string') return ''
|
|
23
|
+
return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the section text.
|
|
28
|
+
* @param {{ accounts: object, resources: object }} services - stores.
|
|
29
|
+
* @returns {string} prompt text.
|
|
30
|
+
*/
|
|
31
|
+
function buildText({ accounts, resources }) {
|
|
32
|
+
/** @type {object[]} */
|
|
33
|
+
let accountViews = []
|
|
34
|
+
/** @type {object[]} */
|
|
35
|
+
let resourceViews = []
|
|
36
|
+
try {
|
|
37
|
+
accountViews = accounts.list()
|
|
38
|
+
} catch {
|
|
39
|
+
accountViews = []
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
resourceViews = resources.list()
|
|
43
|
+
} catch {
|
|
44
|
+
resourceViews = []
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(accountViews)) accountViews = []
|
|
47
|
+
if (!Array.isArray(resourceViews)) resourceViews = []
|
|
48
|
+
|
|
49
|
+
const lines = ['## SSH Helper', '']
|
|
50
|
+
|
|
51
|
+
if (accountViews.length === 0) {
|
|
52
|
+
lines.push('No SSH accounts are configured. The user can add accounts in the SSH Helper panel in the left sidebar.')
|
|
53
|
+
} else {
|
|
54
|
+
lines.push('Configured SSH accounts (credentials are never shown):')
|
|
55
|
+
for (const account of accountViews) {
|
|
56
|
+
const name = singleLine(account.name)
|
|
57
|
+
const username = singleLine(account.username)
|
|
58
|
+
const host = singleLine(account.host)
|
|
59
|
+
const port = typeof account.port === 'number' ? account.port : 22
|
|
60
|
+
const auth = account.authType === 'key' ? 'private key' : 'password'
|
|
61
|
+
lines.push(`- ${name} — ${username}@${host}:${port} (${auth} auth)`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
lines.push('')
|
|
65
|
+
|
|
66
|
+
if (resourceViews.length === 0) {
|
|
67
|
+
lines.push('No deployment resources are catalogued. Discover one with `ssh_helper_discover` (scan/inspect) and save it with `ssh_helper_resources`.')
|
|
68
|
+
} else {
|
|
69
|
+
lines.push('Deployment resources:')
|
|
70
|
+
for (const resource of resourceViews) {
|
|
71
|
+
const name = singleLine(resource.name)
|
|
72
|
+
const serverName = singleLine(resource.serverName) || 'unknown server'
|
|
73
|
+
const appDir = singleLine(resource.appDir)
|
|
74
|
+
const status = singleLine(resource.lastUpdateStatus) || 'never'
|
|
75
|
+
lines.push(`- ${name} — ${serverName}:${appDir} (last update: ${status})`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
lines.push(
|
|
80
|
+
'',
|
|
81
|
+
'Workflow: use `ssh_helper_discover` to find deployment directories, then `ssh_helper_resources` to save them; use `ssh_helper_upload` to push a local build artifact (SFTP, size and SHA-256 verified) when the deployment needs one; use `ssh_helper_update_plan` to produce a read-only plan, then `ssh_helper_update_apply` to execute it; use `ssh_helper_update_rollback` to restore the last backup.',
|
|
82
|
+
'`ssh_helper_upload`, `ssh_helper_update_apply` and `ssh_helper_update_rollback` always ask the user for approval. Never try to bypass approval, never run managed directory changes through a generic `ssh_exec`, and never put passwords, private keys, passphrases or tokens in update commands.',
|
|
83
|
+
)
|
|
84
|
+
return lines.join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register the `## SSH Helper` system-prompt section.
|
|
89
|
+
* @param {object} ctx - host plugin context (`systemPrompt` service).
|
|
90
|
+
* @param {{ accounts: object, resources: object }} services - stores.
|
|
91
|
+
* @returns {() => void} section disposer.
|
|
92
|
+
*/
|
|
93
|
+
export function registerPromptSection(ctx, { accounts, resources } = {}) {
|
|
94
|
+
if (!ctx || !ctx.systemPrompt || typeof ctx.systemPrompt.section !== 'function') {
|
|
95
|
+
throw new Error('dsh-ssh-helper: systemPrompt service is unavailable')
|
|
96
|
+
}
|
|
97
|
+
if (!accounts || !resources) throw new Error('dsh-ssh-helper: prompt services are unavailable')
|
|
98
|
+
return ctx.systemPrompt.section({
|
|
99
|
+
name: SECTION_NAME,
|
|
100
|
+
order: SECTION_ORDER,
|
|
101
|
+
text: () => buildText({ accounts, resources }),
|
|
102
|
+
})
|
|
103
|
+
}
|