dsh-app-deploy 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # dsh-app-deploy
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/dsh-app-deploy)](https://www.npmjs.com/package/dsh-app-deploy)
4
+
3
5
  [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh)(DSH)插件:把交互式部署工具收进 DSH Web 面板。按环境分组管理配置,勾选模块、拖拽编排执行顺序,一键完成 Gradle 构建、rsync 同步与 SSH 远程命令,实时推送日志并保留执行记录。
4
6
 
7
+ ![插件界面](docs/screenshot.webp)
8
+
5
9
  ## 特性
6
10
 
7
11
  - **部署面板**:侧边栏「部署」入口,按环境分组管理,模块勾选 + 拖拽排序,一键部署。
@@ -16,19 +20,23 @@
16
20
  | --- | --- |
17
21
  | Node.js | >= 22 |
18
22
  | DSH | >= 0.2.1-rc.3, < 0.3.0 |
19
- | 部署机工具 | `rsync`、`ssh`;配置 SSH 密码时另需 `sshpass` |
23
+ | 部署机工具 | `rsync`(或 `rclone`)、`ssh`;rsync + SSH 密码时另需 `sshpass`(无 Windows 版) |
24
+
25
+ > **同步工具回退**:本机有 `rsync` 优先用 rsync over ssh;没有 rsync 时自动回退 `rclone`(SFTP 后端):前端模块用 `rclone sync`(自带镜像删除,等价 `--delete`),服务端模块用 `rclone copy`(不删除)。rclone 同步自身支持密码与密钥(密钥取 SSH 自定义选项里的 `-i`),不依赖 sshpass。
26
+ >
27
+ > **Windows 主机**:Gradle 构建(`gradlew.bat`)、JDK 自动发现(`JAVA_HOME` + 常见安装目录)、目录浏览均可原生工作。装一个 [rclone](https://rclone.org) 即可获得完整同步能力(含密码认证);仅当模块配置了远程命令时才需要系统自带 OpenSSH 的 `ssh`——远程命令 + 密码依赖 sshpass(Windows 无此工具),请改用密钥认证(SSH 自定义选项填 `-i 私钥路径`,并清空密码)。
20
28
 
21
29
  ## 安装
22
30
 
23
31
  插件发布在 npm,通过 DSH 插件管理器直接安装:
24
32
 
25
33
  ```text
26
- plugin_manager → action: install_bundle → target: dsh-app-deploy
34
+ plugin_manager → action: install_bundle → target: dsh-app-deploy@0.2.0
27
35
  ```
28
36
 
29
37
  安装后刷新 DSH Web,左侧导航出现「部署」入口。
30
38
 
31
- 卸载用 `plugin_manager` 的 `remove_bundle`;升级先卸载再以同样方式安装(`install_bundle` 按 `name@version` 精确锁定当前发布版本)。
39
+ 卸载用 `plugin_manager` 的 `remove_bundle`;升级先卸载再安装新版本(`target` 换成新的 `dsh-app-deploy@<version>`)。
32
40
 
33
41
  ## 配置
34
42
 
@@ -48,6 +56,7 @@ plugin_manager → action: install_bundle → target: dsh-app-deploy
48
56
  ```
49
57
  清理构建目录 → ./gradlew <task> [-Pkey] [额外参数] [-PexpTime]
50
58
  → rsync [-r -t -z] 本地产物 → user@host:远程路径(前端加 --delete)
59
+ (无 rsync 时回退 rclone:前端 rclone sync / 服务端 rclone copy,走 SFTP)
51
60
  → ssh 远程命令(可空)
52
61
  → 健康检查(可空:HTTP 2xx + success=true + code=200)
53
62
  ```
package/client.js CHANGED
@@ -48,17 +48,19 @@ window.__ModuleLoader__.load({
48
48
  }
49
49
  function clone(v) { return JSON.parse(JSON.stringify(v)) }
50
50
  function normPath(p) { return (p || '').replace(/\/+$/, '') }
51
+ // 绝对路径:posix 以 / 开头,Windows 为盘符或 \ 开头(如 C:\proj、D:/proj)
52
+ function isAbsPath(p) { return /^([a-zA-Z]:[\\/]|[\\/])/.test(p || '') }
51
53
  function joinPath(base, name) {
52
54
  if (!name) return base || ''
53
- if (name.startsWith('/')) return name
55
+ if (isAbsPath(name)) return name
54
56
  const b = base || ''
55
57
  if (!b) return name
56
- return b === '/' ? '/' + name : b.replace(/\/+$/, '') + '/' + name
58
+ return b === '/' ? '/' + name : b.replace(/[\\/]+$/, '') + '/' + name
57
59
  }
58
60
  function parentPath(p) {
59
61
  const base = normPath(p || '')
60
- if (!base || base === '/') return '/'
61
- const i = base.lastIndexOf('/')
62
+ if (!base || base === '/' || /^[a-zA-Z]:[\\/]?$/.test(base)) return base || '/'
63
+ const i = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'))
62
64
  return i <= 0 ? '/' : base.slice(0, i)
63
65
  }
64
66
  function newEnvId(envs) {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-app-deploy",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DSH 插件:按环境编排并执行 Gradle 构建、rsync 同步与 SSH 远程命令的部署面板,实时推送日志并保留执行记录。",
5
+ "repository": "btboys/dsh-app-deploy",
5
6
  "type": "module",
6
7
  "icon": "./icon.svg",
7
8
  "exports": {
package/server/config.js CHANGED
@@ -138,6 +138,8 @@ export function getDefaultProjectPath() { return defaultProjectPath }
138
138
 
139
139
  // ---------- 校验 ----------
140
140
  const BAD_PATH = /[\s;"'`$&|<>()*?[\]{}!]/ // 禁止空格/引号/; & | ` $ 等
141
+ // 绝对路径:posix 以 / 开头,Windows 为盘符 + \ 或 /(如 C:\jdk、D:/proj)
142
+ const isAbsPath = (p) => /^(\/|[a-zA-Z]:[\\/])/.test(p)
141
143
  const BAD_CMD = /["'`;&|<>$()*?[\]{}!]/ // 允许空格,其余同上
142
144
 
143
145
  // 前端 .env 文本:只允许 KEY=VALUE(KEY 为 A-Z0-9_)、空行、# 注释
@@ -152,11 +154,11 @@ export function validateFrontEnv(s) {
152
154
 
153
155
  // 前端 .env 文件路径(仅种子 bsFront/adminFront 有约定路径,供「配置为空时读文件作参考」)
154
156
  export function frontEnvFile(e, m) {
155
- const p = (e.projectPath || defaultProjectPath || '').replace(/\/+$/, '')
157
+ const p = (e.projectPath || defaultProjectPath || '').replace(/[\\/]+$/, '')
156
158
  if (!p) return null
157
159
  const key = m.key ? '.' + m.key : ''
158
- if (m.id === 'bsFront') return `${p}/client/client-front/.env${key}`
159
- if (m.id === 'adminFront') return `${p}/admin/admin-front/.env${key}`
160
+ if (m.id === 'bsFront') return path.join(p, 'client/client-front', `.env${key}`)
161
+ if (m.id === 'adminFront') return path.join(p, 'admin/admin-front', `.env${key}`)
160
162
  return null
161
163
  }
162
164
 
@@ -165,9 +167,9 @@ export function validateEnv(e) {
165
167
  if (!/^[a-z0-9-]+$/.test(e.id)) errs.push('ID 只能是小写字母/数字/连字符')
166
168
  if (!e.name.trim()) errs.push('名称必填')
167
169
  if (e.group && !/^[\u4e00-\u9fa5A-Za-z0-9 -]{1,20}$/.test(e.group)) errs.push('分组只能含中文/字母/数字/空格/连字符,最长 20 字符')
168
- if (!e.projectPath.startsWith('/')) errs.push('项目路径必须是绝对路径')
170
+ if (!isAbsPath(e.projectPath)) errs.push('项目路径必须是绝对路径(如 /opt/proj 或 C:\\proj)')
169
171
  if (BAD_PATH.test(e.projectPath)) errs.push('项目路径含非法字符(禁止空格/引号/; & | ` $ 等)')
170
- if (e.jdkHome && !e.jdkHome.startsWith('/')) errs.push('JDK 路径必须是绝对路径')
172
+ if (e.jdkHome && !isAbsPath(e.jdkHome)) errs.push('JDK 路径必须是绝对路径')
171
173
  if (e.jdkHome && BAD_PATH.test(e.jdkHome)) errs.push('JDK 路径含非法字符')
172
174
  const s = e.ssh
173
175
  if (!/^[a-zA-Z0-9.-]+$/.test(s.host)) errs.push('SSH host 非法(仅字母/数字/./-)')
@@ -3,10 +3,13 @@ import { promises as fsp } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { getEnvs } from './config.js'
5
5
  import { getHistory, saveHistory, LOGTAIL_CHARS } from './history.js'
6
- import { listWorktrees, which } from './local-tools.js'
6
+ import { listWorktrees, which, rcloneObscure } from './local-tools.js'
7
7
  import { checkHealth } from './health-check.js'
8
8
 
9
9
  // ---------- SSE 日志与部署锁(同一时刻只允许一个部署) ----------
10
+ const IS_WIN = process.platform === 'win32'
11
+ // Windows 上 gradlew 是 .bat,必须经 cmd 启动(shell:true 仅用于 win32;参数均过 BAD_CMD 白名单校验)
12
+ const GRADLEW = IS_WIN ? 'gradlew.bat' : './gradlew'
10
13
  let running = null // { clients: Set<ServerResponse>, done, label, record, logTail, lastCode }
11
14
  let activeProc = null // 当前正在执行的子进程(gradlew/rsync/ssh),插件卸载时终止
12
15
 
@@ -42,7 +45,11 @@ setInterval(() => {
42
45
  // 插件卸载/重载时终止正在运行的构建子进程,避免 gradlew 成为孤儿继续跑;
43
46
  // gradlew 客户端被杀后,daemon 端会因连接断开取消本次构建
44
47
  export function killActiveProcess() {
45
- try { activeProc?.kill() } catch {}
48
+ // Windows 上构建进程跑在 cmd.exe 下,kill() 只杀 cmd 会留下 java 孤儿,必须 taskkill 整棵树
49
+ try {
50
+ if (activeProc?.pid && IS_WIN) spawn('taskkill', ['/pid', String(activeProc.pid), '/t', '/f'])
51
+ else activeProc?.kill()
52
+ } catch {}
46
53
  activeProc = null
47
54
  }
48
55
 
@@ -59,7 +66,7 @@ function runStep(label, args, opts = {}) {
59
66
  }
60
67
  let child
61
68
  try {
62
- child = spawn(args[0], args.slice(1), { cwd: opts.cwd, env: opts.env, stdio: ['ignore', 'pipe', 'pipe'] })
69
+ child = spawn(args[0], args.slice(1), { cwd: opts.cwd, env: opts.env, shell: opts.shell, stdio: ['ignore', 'pipe', 'pipe'] })
63
70
  activeProc = child
64
71
  } catch (err) {
65
72
  broadcast(`启动失败: ${String(err)}\n`)
@@ -79,7 +86,7 @@ async function stopGradleDaemon(env) {
79
86
  try {
80
87
  const stopEnv = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : process.env
81
88
  await new Promise((resolve) => {
82
- const p = spawn('./gradlew', ['--stop'], { cwd: env.projectPath, env: stopEnv, stdio: 'ignore' })
89
+ const p = spawn(GRADLEW, ['--stop'], { cwd: env.projectPath, env: stopEnv, shell: IS_WIN, stdio: 'ignore' })
83
90
  p.once('error', resolve)
84
91
  p.once('close', resolve)
85
92
  })
@@ -125,25 +132,27 @@ async function rmTree(p) {
125
132
  // 前端(kind=front):取 localPath 中 dist 及之前部分(client/client-front/dist/fxy/ → …/dist)
126
133
  // 服务端(kind=server):清空 localPath 所在目录(…/build/libs/financial-v5.jar → …/build/libs;目录型 localPath 清空自身)
127
134
  export function cleanTarget(env, m) {
128
- const pp = env.projectPath.replace(/\/+$/, '')
129
- const local = m.localPath.startsWith('/') ? m.localPath : `${pp}/${m.localPath}`
135
+ const pp = path.resolve(env.projectPath.replace(/[\\/]+$/, ''))
136
+ const local = path.isAbsolute(m.localPath) ? m.localPath : path.join(pp, m.localPath)
137
+ // 统一成正斜杠视图做推导,兼容 Windows 反斜杠路径
138
+ const lv = local.replace(/\\/g, '/')
130
139
  let rel
131
140
  if (m.kind === 'front') {
132
- const i = local.lastIndexOf('/dist')
141
+ const i = lv.lastIndexOf('/dist')
133
142
  if (i < 0) return null
134
- rel = local.slice(0, i + 5)
143
+ rel = lv.slice(0, i + 5)
135
144
  } else {
136
- const isDir = local.endsWith('/')
137
- const p = local.replace(/\/+$/, '')
145
+ // 目录型(以 / 或 \ 结尾)清空自身,文件型清空所在目录
146
+ const isDir = /[\\/]$/.test(local)
147
+ const p = lv.replace(/\/+$/, '')
138
148
  rel = isDir ? p : p.slice(0, p.lastIndexOf('/'))
139
149
  }
140
- const clean = rel.replace(/\/+/g, '/').replace(/\/+$/, '')
141
150
  // 安全约束:不含 ..、解析后须在 projectPath 内、深度 ≥ projectPath 下 2 级
142
- if (!clean || clean.includes('..')) return null
143
- const resolved = clean.startsWith('/') ? clean : `${pp}/${clean}`
144
- if (!resolved.startsWith(pp + '/')) return null
145
- const depth = resolved.slice(pp.length + 1).split('/').length
146
- if (depth < 2) return null
151
+ if (!rel || rel.includes('..')) return null
152
+ const resolved = path.resolve(rel.replace(/\/+/g, '/'))
153
+ const relToPp = path.relative(pp, resolved)
154
+ if (relToPp.startsWith('..') || path.isAbsolute(relToPp)) return null
155
+ if (relToPp.split(/[\\/]/).length < 2) return null
147
156
  return resolved
148
157
  }
149
158
 
@@ -167,14 +176,29 @@ async function cleanBuildDir(env, m) {
167
176
  }
168
177
  }
169
178
 
179
+ // 组装 rclone 同步参数(SFTP 后端):sync 自带 --delete 用于前端镜像,copy 不删用于服务端;
180
+ // 密码用 obscure 混淆值(只进 spawn 参数,不进日志标签);密钥取 SSH 自定义选项里的 -i
181
+ export function buildRcloneArgs(env, m, local, rclone) {
182
+ const isFront = m.kind === 'front'
183
+ const args = [isFront ? 'sync' : 'copy', local, `:sftp:${m.remotePath}`,
184
+ '--sftp-host', env.ssh.host, '--sftp-port', String(env.ssh.port), '--sftp-user', env.ssh.user,
185
+ '--stats-one-line', '--stats', '10s', '-v']
186
+ if (isFront) args.push('--exclude', '.user.ini') // 宝塔锁定文件;exclude 同时豁免 sync 的删除
187
+ if (rclone.pass) args.push('--sftp-pass', rclone.pass)
188
+ const key = (env.ssh.options || '').match(/(?:^|\s)-i\s+(\S+)/)
189
+ if (!rclone.pass && key) args.push('--sftp-key-file', key[1])
190
+ return args
191
+ }
192
+
170
193
  // 返回退出码与失败阶段(清理/构建/同步/远程命令),用于执行记录
171
- export async function deployModule(env, m, sshBase, dest, sshpass) {
194
+ // rclone 非空时用 rclone(SFTP)替代 rsync 同步;rsyncEnv 同时承载 sshpass 的 SSHPASS
195
+ export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null) {
172
196
  // 0. 清理构建目录(恢复原 update-*.sh 行为)
173
197
  const c0 = await cleanBuildDir(env, m)
174
198
  if (c0 !== 0) return { code: c0, step: '清理' }
175
199
 
176
200
  // 1. 构建
177
- const buildArgs = ['./gradlew', m.gradleTask]
201
+ const buildArgs = [GRADLEW, m.gradleTask]
178
202
  if (m.key) buildArgs.push(`-Pkey=${m.key}`)
179
203
  if (m.extraArgs) buildArgs.push(...m.extraArgs.trim().split(/\s+/).filter(Boolean))
180
204
  if (m.kind === 'server' && env.expTime) buildArgs.push(`-PexpTime=${env.expTime}`)
@@ -191,17 +215,26 @@ export async function deployModule(env, m, sshBase, dest, sshpass) {
191
215
  if (/^[A-Za-z0-9_]+$/.test(k)) envForGradle[k] = v
192
216
  }
193
217
  }
194
- const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle })
218
+ const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle, shell: IS_WIN })
195
219
  if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
196
220
 
197
- // 2. rsync(--delete 仅用于前端模块,与原脚本行为一致)
198
- const local = m.localPath.startsWith('/') ? m.localPath : `${env.projectPath.replace(/\/+$/, '')}/${m.localPath}`
199
- const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
221
+ // 2. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
200
222
  const isFront = m.kind === 'front'
201
- if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
202
- rsyncArgs.push(local, `${dest}:${m.remotePath}`)
223
+ // 保留 rsync 的目录尾斜杠语义:dist/ 传内容本身,dist 传 dist 目录
224
+ const isDirLike = /[\\/]$/.test(m.localPath)
225
+ const joined = path.isAbsolute(m.localPath) ? m.localPath : path.join(env.projectPath, m.localPath)
226
+ const local = joined.replace(/[\\/]+$/, '') + (isDirLike ? '/' : '')
203
227
  const rsyncEnv = sshpass ? { ...process.env, SSHPASS: env.ssh.password } : process.env
204
- const c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
228
+ let c2
229
+ if (rclone) {
230
+ const args = buildRcloneArgs(env, m, local, rclone)
231
+ c2 = await runStep(`[${m.name}] rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
232
+ } else {
233
+ const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
234
+ if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
235
+ rsyncArgs.push(local, `${dest}:${m.remotePath}`)
236
+ c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
237
+ }
205
238
  if (c2 !== 0) { broadcast(`[${m.name}] 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: '同步' } }
206
239
 
207
240
  // 3. ssh 远程命令
@@ -223,17 +256,43 @@ export async function deployModule(env, m, sshBase, dest, sshpass) {
223
256
  async function runDeployment(env, mods) {
224
257
  let code = 1
225
258
  try {
226
- const sshpass = env.ssh.password ? await which('sshpass') : null
227
- if (env.ssh.password && !sshpass) {
228
- broadcast('错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
259
+ // 预检本地工具,缺失时给出可操作的提示(避免到同步步骤才报 127 启动失败)
260
+ // 同步优先 rsync,缺失回退 rclone(SFTP);ssh 仅 rsync 传输或有远程命令时需要
261
+ const hasRsync = await which('rsync')
262
+ const rcloneBin = hasRsync ? null : await which('rclone')
263
+ const needSsh = mods.some(m => m.enabled && m.postCommand.trim())
264
+ const missing = []
265
+ if (!hasRsync && !rcloneBin) missing.push('rsync(或 rclone)')
266
+ if ((hasRsync || needSsh) && !(await which('ssh'))) missing.push('ssh')
267
+ // sshpass:rsync 密码传输恒需要;rclone 模式自身支持密码,仅「密码 + 远程命令」时才需要
268
+ const needSshpass = !!env.ssh.password && (hasRsync || needSsh)
269
+ const sshpass = needSshpass ? await which('sshpass') : null
270
+ if (missing.length) {
271
+ broadcast(`错误: 本机未找到 ${missing.join('、')}(须在 PATH 中)。${IS_WIN ? 'Windows 可安装 rclone 替代 rsync(https://rclone.org),ssh 用系统自带 OpenSSH' : '请先安装 rsync(或 rclone)与 OpenSSH'}\n`)
272
+ code = 127
273
+ } else if (needSshpass && !sshpass) {
274
+ broadcast(IS_WIN
275
+ ? '错误: 远程命令 + SSH 密码需要 sshpass,而 Windows 无此工具;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码),或移除远程命令让 rclone 直接同步\n'
276
+ : '错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
229
277
  code = 127
230
278
  } else {
231
279
  const sshBase = `ssh -p ${env.ssh.port}${env.ssh.options ? ' ' + env.ssh.options : ''}`
232
280
  const dest = `${env.ssh.user}@${env.ssh.host}`
281
+ // rclone 密码认证走 obscure 混淆值(失败立即中止,避免到同步步骤才报错)
282
+ let rclone = null
283
+ if (rcloneBin) {
284
+ const pass = env.ssh.password ? await rcloneObscure(rcloneBin, env.ssh.password) : null
285
+ if (env.ssh.password && !pass) {
286
+ broadcast('错误: rclone obscure 密码处理失败,无法使用密码认证\n')
287
+ code = 127
288
+ return finishDeploy(code) // 尚未构建,无需停 daemon
289
+ }
290
+ rclone = { bin: rcloneBin, pass }
291
+ }
233
292
  code = 0
234
293
  for (const m of mods) {
235
294
  if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
236
- const res = await deployModule(env, m, sshBase, dest, sshpass)
295
+ const res = await deployModule(env, m, sshBase, dest, sshpass, rclone)
237
296
  if (res.code !== 0) {
238
297
  if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
239
298
  code = res.code
@@ -16,13 +16,22 @@ function run(cmd, args, options = {}) {
16
16
  })
17
17
  }
18
18
 
19
- // 等价旧 Bun.which:找到返回绝对路径,找不到返回 null
19
+ const IS_WIN = process.platform === 'win32'
20
+
21
+ // 等价旧 Bun.which:找到返回绝对路径,找不到返回 null(Windows 用 where)
20
22
  export async function which(command) {
21
- const { code, stdout } = await run('which', [command])
22
- const found = stdout.trim()
23
+ const { code, stdout } = await run(IS_WIN ? 'where' : 'which', [command])
24
+ const found = stdout.split('\n')[0].trim()
23
25
  return code === 0 && found ? found : null
24
26
  }
25
27
 
28
+ // rclone obscure 密码混淆(--sftp-pass 只接受混淆值);失败返回 null
29
+ export async function rcloneObscure(bin, password) {
30
+ const { code, stdout } = await run(bin, ['obscure', password])
31
+ const v = stdout.trim()
32
+ return code === 0 && v ? v : null
33
+ }
34
+
26
35
  // 列出目录所在 git 仓库的全部 worktree(含主工作区);非 git 仓库/失败返回空数组
27
36
  export async function listWorktrees(dir) {
28
37
  const worktrees = []
@@ -98,16 +107,32 @@ export async function listJdks() {
98
107
  for (const v of vers) found.add(path.join(vdir, v, 'libexec/openjdk.jdk/Contents/Home'))
99
108
  }
100
109
  } catch {}
110
+ // Windows:JAVA_HOME + 常见厂商安装目录(Oracle / Temurin / Microsoft / Zulu / Corretto)
111
+ if (IS_WIN) {
112
+ if (process.env.JAVA_HOME) found.add(process.env.JAVA_HOME)
113
+ const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean)
114
+ const vendors = ['Java', 'Eclipse Adoptium', 'Eclipse Foundation', 'Microsoft', 'Zulu', 'Amazon Corretto']
115
+ for (const root of roots) {
116
+ for (const vendor of vendors) {
117
+ try { for (const n of await fsp.readdir(path.join(root, vendor))) found.add(path.join(root, vendor, n)) } catch {}
118
+ }
119
+ }
120
+ }
101
121
  // 只保留 bin/java 真实存在者,生成友好短名
102
122
  const jdks = []
103
- for (const p of found) if (await fileExists(`${p}/bin/java`)) {
104
- const m = p.match(/\/JavaVirtualMachines\/([^/]+)\.jdk\/Contents\/Home$/)
123
+ for (const p of found) if (await fileExists(path.join(p, 'bin', IS_WIN ? 'java.exe' : 'java'))) {
124
+ // 标签推导统一用正斜杠视图(Windows 反斜杠路径也能复用同一套正则)
125
+ const pv = p.replace(/\\/g, '/')
126
+ const m = pv.match(/\/JavaVirtualMachines\/([^/]+)\.jdk\/Contents\/Home$/)
105
127
  let label
106
128
  if (m) label = (m[1].match(/\d+(?:\.\d+)*/) || [])[1] || m[1]
107
- else if (p.includes('/opt/homebrew/')) {
108
- const v = (p.match(/openjdk@?(\d+)/) || [])[1] || (p.match(/\/(\d+(?:\.\d+)+)\/libexec/) || [])[1]
129
+ else if (pv.includes('/opt/homebrew/')) {
130
+ const v = (pv.match(/openjdk@?(\d+)/) || [])[1] || (pv.match(/\/(\d+(?:\.\d+)+)\/libexec/) || [])[1]
109
131
  label = v ? 'homebrew-' + v : 'homebrew'
110
- } else label = p.split('/').filter(Boolean).pop()
132
+ } else if (/^[a-zA-Z]:\//.test(pv)) {
133
+ const segs = pv.split('/').filter(Boolean)
134
+ label = segs.pop() || pv
135
+ } else label = pv.split('/').filter(Boolean).pop()
111
136
  jdks.push({ label, path: p })
112
137
  }
113
138
  // 短名去重:同名者追加版本/opt-cellar 段区分
@@ -139,7 +164,7 @@ export async function listJdks() {
139
164
  export async function browseDirectory(args = {}) {
140
165
  let p = typeof args.path === 'string' ? args.path : ''
141
166
  const showHidden = args.hidden === true || args.hidden === '1' || args.hidden === 1
142
- if (!p) p = process.env.HOME || '/'
167
+ if (!p) p = process.env.HOME || (IS_WIN ? (process.env.USERPROFILE || 'C:\\') : '/')
143
168
  if (!path.isAbsolute(p)) throw new Error('必须为绝对路径')
144
169
  p = path.resolve(p)
145
170
  try {
package/server/rpc.js CHANGED
@@ -4,6 +4,11 @@ import { getHistory, replaceHistory, HIST_LIMIT } from './history.js'
4
4
  import { activeRecord } from './deployment.js'
5
5
  import { listJdks, listWorktrees, readGitInfo, browseDirectory, which } from './local-tools.js'
6
6
 
7
+ // sshpass 无 Windows 版本;警告文案按平台给出可操作的下一步(保存/导入两处共用)
8
+ const SSHPASS_HINT = process.platform === 'win32'
9
+ ? '但本机没有 sshpass(Windows 无此工具),部署时无法使用密码认证;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码)'
10
+ : '但本机未安装 sshpass,部署时无法使用密码认证。请先执行: brew install hudochenkov/sshpass/sshpass'
11
+
7
12
  // JSON-RPC 方法表:与旧 server.ts 的 /api/* 逐一对齐(字段名不变)
8
13
  const methods = {
9
14
  config: () => {
@@ -27,7 +32,7 @@ const methods = {
27
32
  await saveEnvs()
28
33
  let warning = ''
29
34
  if (env.ssh.password && !(await which('sshpass'))) {
30
- warning = '已保存,但本机未安装 sshpass,部署时无法使用密码认证。请先执行: brew install hudochenkov/sshpass/sshpass'
35
+ warning = '已保存,' + SSHPASS_HINT
31
36
  }
32
37
  return { envs: getEnvs(), warning }
33
38
  },
@@ -68,7 +73,7 @@ const methods = {
68
73
  const touched = new Set([...added, ...overwritten])
69
74
  let warning = ''
70
75
  if (getEnvs().some(e => touched.has(e.id) && e.ssh.password) && !(await which('sshpass'))) {
71
- warning = '已导入,但本机未安装 sshpass,部署时无法使用密码认证。请先执行: brew install hudochenkov/sshpass/sshpass'
76
+ warning = '已导入,' + SSHPASS_HINT
72
77
  }
73
78
  return { envs: getEnvs(), added, overwritten, warning }
74
79
  },