dsh-app-deploy 0.1.0 → 0.3.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 +13 -4
- package/client.js +352 -121
- package/package.json +3 -2
- package/server/config.js +92 -31
- package/server/deployment.js +179 -46
- package/server/local-tools.js +105 -10
- package/server/rpc.js +18 -6
package/server/local-tools.js
CHANGED
|
@@ -16,13 +16,22 @@ function run(cmd, args, options = {}) {
|
|
|
16
16
|
})
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
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(
|
|
104
|
-
|
|
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 (
|
|
108
|
-
const v = (
|
|
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
|
|
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 段区分
|
|
@@ -135,11 +160,78 @@ export async function listJdks() {
|
|
|
135
160
|
return out.sort((a, b) => a.label.localeCompare(b.label))
|
|
136
161
|
}
|
|
137
162
|
|
|
163
|
+
// 探测本机包管理器可执行文件(npm/pnpm/yarn),返回 [{label, path}],path 为可执行文件路径
|
|
164
|
+
export async function listPackageManagers() {
|
|
165
|
+
const home = process.env.HOME || process.env.USERPROFILE || ''
|
|
166
|
+
const exts = IS_WIN ? ['.exe', '.cmd', '.ps1', ''] : ['']
|
|
167
|
+
const candidates = []
|
|
168
|
+
const tryBin = async (dir, name) => {
|
|
169
|
+
for (const ext of exts) {
|
|
170
|
+
const p = path.join(dir, name + ext)
|
|
171
|
+
if (await fileExists(p)) { candidates.push({ label: name, path: p }); return }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// PATH 中的(which 返回可执行文件全路径)
|
|
175
|
+
for (const name of ['npm', 'pnpm', 'yarn']) {
|
|
176
|
+
const w = await which(name).catch(() => '')
|
|
177
|
+
if (w) candidates.push({ label: name, path: w })
|
|
178
|
+
}
|
|
179
|
+
// pnpm/yarn 全局 bin 的常见固定位置(不经 PATH)
|
|
180
|
+
if (home) {
|
|
181
|
+
await tryBin(path.join(home, '.local/share/pnpm'), 'pnpm')
|
|
182
|
+
await tryBin(path.join(home, '.volta/bin'), 'pnpm')
|
|
183
|
+
await tryBin(path.join(home, '.volta/bin'), 'yarn')
|
|
184
|
+
}
|
|
185
|
+
await tryBin('/opt/homebrew/bin', 'pnpm')
|
|
186
|
+
await tryBin('/usr/local/bin', 'pnpm')
|
|
187
|
+
await tryBin('/opt/homebrew/bin', 'yarn')
|
|
188
|
+
// 去重(同路径只留一个)
|
|
189
|
+
const seen = new Set()
|
|
190
|
+
return candidates.filter((c) => (seen.has(c.path) ? false : (seen.add(c.path), true)))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 探测本机已装 Node(nvm / n / fnm / volta / nvm-windows),返回 [{label, path}],path 为版本目录(含 bin/node)
|
|
194
|
+
export async function listNodes() {
|
|
195
|
+
const found = new Set()
|
|
196
|
+
const home = process.env.HOME || process.env.USERPROFILE || ''
|
|
197
|
+
const scan = async (base) => {
|
|
198
|
+
try { for (const n of await fsp.readdir(base)) found.add(path.join(base, n)) } catch {}
|
|
199
|
+
}
|
|
200
|
+
if (home) {
|
|
201
|
+
await scan(path.join(home, '.nvm/versions/node'))
|
|
202
|
+
await scan(path.join(home, '.local/share/fnm/node-versions'))
|
|
203
|
+
await scan(path.join(home, '.volta/tools/image/node'))
|
|
204
|
+
}
|
|
205
|
+
await scan('/usr/local/n/versions/node')
|
|
206
|
+
await scan('/usr/lib/n/versions/node')
|
|
207
|
+
if (IS_WIN && process.env.NVM_HOME) await scan(process.env.NVM_HOME)
|
|
208
|
+
const binName = IS_WIN ? 'node.exe' : 'node'
|
|
209
|
+
const out = []
|
|
210
|
+
for (const p of found) {
|
|
211
|
+
// 支持 <dir>/bin/node、<dir>/node.exe(nvm-windows)、<dir>/installation/bin/node(fnm)
|
|
212
|
+
for (const cand of [p, path.join(p, 'installation')]) {
|
|
213
|
+
if (await fileExists(path.join(cand, 'bin', binName)) || await fileExists(path.join(cand, binName))) {
|
|
214
|
+
const label = path.basename(cand).replace(/^v/, '')
|
|
215
|
+
out.push({ label, path: cand })
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 版本号倒序,新者在前
|
|
221
|
+
out.sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }))
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
138
225
|
// 目录浏览:非法/不可读抛出错误,由 RPC 层转成 ok:false
|
|
139
226
|
export async function browseDirectory(args = {}) {
|
|
140
227
|
let p = typeof args.path === 'string' ? args.path : ''
|
|
141
228
|
const showHidden = args.hidden === true || args.hidden === '1' || args.hidden === 1
|
|
142
|
-
|
|
229
|
+
const pickFile = args.file === true || args.file === '1' || args.file === 1
|
|
230
|
+
// 展开 ~/(私钥选择等场景直接从 ~/.ssh 起步)
|
|
231
|
+
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
|
|
232
|
+
p = path.join(process.env.HOME || process.env.USERPROFILE || '/', p.slice(2))
|
|
233
|
+
}
|
|
234
|
+
if (!p) p = process.env.HOME || (IS_WIN ? (process.env.USERPROFILE || 'C:\\') : '/')
|
|
143
235
|
if (!path.isAbsolute(p)) throw new Error('必须为绝对路径')
|
|
144
236
|
p = path.resolve(p)
|
|
145
237
|
try {
|
|
@@ -148,7 +240,10 @@ export async function browseDirectory(args = {}) {
|
|
|
148
240
|
const dirs = entries.filter(d => d.isDirectory() && (showHidden || !d.name.startsWith('.'))).map(d => d.name).sort().slice(0, 200)
|
|
149
241
|
// 若当前目录属于 git 仓库(含子目录/任意 worktree),列出全部 worktree 供前端切换
|
|
150
242
|
const worktrees = await listWorktrees(p)
|
|
151
|
-
|
|
243
|
+
const out = { path: p, parent, dirs, worktrees }
|
|
244
|
+
// 文件选择模式(如私钥):额外列出普通文件
|
|
245
|
+
if (pickFile) out.files = entries.filter(d => d.isFile() && (showHidden || !d.name.startsWith('.'))).map(d => d.name).sort().slice(0, 200)
|
|
246
|
+
return out
|
|
152
247
|
} catch {
|
|
153
248
|
throw new Error('目录不可读')
|
|
154
249
|
}
|
package/server/rpc.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { promises as fsp } from 'node:fs'
|
|
2
2
|
import { getEnvs, saveEnvs, normalizeEnv, isNewSchema, templateEnv, validateEnv, frontEnvFile, getDefaultProjectPath } from './config.js'
|
|
3
3
|
import { getHistory, replaceHistory, HIST_LIMIT } from './history.js'
|
|
4
|
-
import { activeRecord } from './deployment.js'
|
|
5
|
-
import { listJdks, listWorktrees, readGitInfo, browseDirectory, which } from './local-tools.js'
|
|
4
|
+
import { activeRecord, testSsh } from './deployment.js'
|
|
5
|
+
import { listJdks, listNodes, listPackageManagers, listWorktrees, readGitInfo, browseDirectory, which } from './local-tools.js'
|
|
6
|
+
|
|
7
|
+
// sshpass 无 Windows 版本;警告文案按平台给出可操作的下一步(保存/导入两处共用)
|
|
8
|
+
const SSHPASS_HINT = process.platform === 'win32'
|
|
9
|
+
? '但本机没有 sshpass(Windows 无此工具),部署时无法使用密码认证;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码)'
|
|
10
|
+
: '但本机未安装 sshpass,部署时无法使用密码认证。请先执行: brew install hudochenkov/sshpass/sshpass'
|
|
6
11
|
|
|
7
12
|
// JSON-RPC 方法表:与旧 server.ts 的 /api/* 逐一对齐(字段名不变)
|
|
8
13
|
const methods = {
|
|
@@ -26,8 +31,8 @@ const methods = {
|
|
|
26
31
|
if (i >= 0) envs[i] = env; else envs.push(env)
|
|
27
32
|
await saveEnvs()
|
|
28
33
|
let warning = ''
|
|
29
|
-
if (env.
|
|
30
|
-
warning = '
|
|
34
|
+
if ((env.sshs || []).some(s => s.password) && !(await which('sshpass'))) {
|
|
35
|
+
warning = '已保存,' + SSHPASS_HINT
|
|
31
36
|
}
|
|
32
37
|
return { envs: getEnvs(), warning }
|
|
33
38
|
},
|
|
@@ -67,8 +72,8 @@ const methods = {
|
|
|
67
72
|
await saveEnvs(order.map(id => byId.get(id)))
|
|
68
73
|
const touched = new Set([...added, ...overwritten])
|
|
69
74
|
let warning = ''
|
|
70
|
-
if (getEnvs().some(e => touched.has(e.id) && e.
|
|
71
|
-
warning = '
|
|
75
|
+
if (getEnvs().some(e => touched.has(e.id) && (e.sshs || []).some(s => s.password)) && !(await which('sshpass'))) {
|
|
76
|
+
warning = '已导入,' + SSHPASS_HINT
|
|
72
77
|
}
|
|
73
78
|
return { envs: getEnvs(), added, overwritten, warning }
|
|
74
79
|
},
|
|
@@ -84,6 +89,13 @@ const methods = {
|
|
|
84
89
|
try { return { text: await fsp.readFile(p, 'utf8') } } catch { return { text: '' } }
|
|
85
90
|
},
|
|
86
91
|
jdks: () => listJdks(),
|
|
92
|
+
nodes: () => listNodes(),
|
|
93
|
+
packageManagers: () => listPackageManagers(),
|
|
94
|
+
// 测试 SSH 连通性:target 直接用表单当前值(无需先保存环境)
|
|
95
|
+
sshTest: async ({ target } = {}) => {
|
|
96
|
+
if (!target || typeof target !== 'object') throw new Error('缺少测试目标')
|
|
97
|
+
return testSsh(target)
|
|
98
|
+
},
|
|
87
99
|
browse: (args) => browseDirectory(args ?? {}),
|
|
88
100
|
git: async ({ env: id, path: sel } = {}) => {
|
|
89
101
|
const e = getEnvs().find(x => x.id === id)
|