dsh-app-deploy 0.0.0-stage → 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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-app-deploy
3
+ name: dsh-app-deploy
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M12 3v10"/>
3
+ <path d="M8.5 6.5 12 3l3.5 3.5"/>
4
+ <path d="M4 14v4a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-4"/>
5
+ <path d="M8 17h8"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,102 @@
1
+ import { createRequire } from 'node:module'
2
+ import { realpathSync } from 'node:fs'
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { initConfig } from './server/config.js'
7
+ import { initHistory } from './server/history.js'
8
+ import { handleRpc } from './server/rpc.js'
9
+ import { handleDeploy, killActiveProcess } from './server/deployment.js'
10
+
11
+ // ponytail: linked workspace cannot resolve dsh's own packages; resolve from the dsh entry instead
12
+ function hostRequire() {
13
+ const local = createRequire(import.meta.url)
14
+ try {
15
+ local.resolve('@deepseek-ai/dsh-tools')
16
+ return local
17
+ } catch {}
18
+ if (!process.argv[1]) throw new Error('找不到 dsh 安装目录')
19
+ return createRequire(realpathSync(process.argv[1]))
20
+ }
21
+ const host = hostRequire()
22
+ const schema = host('@deepseek-ai/schemastery')
23
+ const z = schema.z ?? schema
24
+
25
+ export const inject = ['webServer']
26
+ export const Config = z.object({
27
+ dataDir: z.string().default(''),
28
+ defaultProjectPath: z.string().default(''),
29
+ })
30
+
31
+ const ROUTE = '/api/dsh-app-deploy'
32
+ const ROUTE_DEPLOY = `${ROUTE}/deploy`
33
+
34
+ function readJson(req) {
35
+ return new Promise((resolve, reject) => {
36
+ const chunks = []
37
+ req.on('data', (chunk) => chunks.push(chunk))
38
+ req.on('end', () => {
39
+ try {
40
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
41
+ } catch (error) {
42
+ reject(error)
43
+ }
44
+ })
45
+ req.on('error', reject)
46
+ })
47
+ }
48
+
49
+ function send(res, payload) {
50
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
51
+ res.end(JSON.stringify(payload))
52
+ }
53
+
54
+ function resolveDataDir(config) {
55
+ const custom = typeof config?.dataDir === 'string' ? config.dataDir.trim() : ''
56
+ if (custom) return custom
57
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-app-deploy')
58
+ }
59
+
60
+ export function apply(ctx, config) {
61
+ const dataDir = resolveDataDir(config)
62
+ // 延迟初始化:apply 内不 await,首个请求时才读数据目录/初始化配置
63
+ let ready
64
+ const ensure = () => (ready ??= (async () => {
65
+ await mkdir(dataDir, { recursive: true })
66
+ await initConfig({ dataDir, defaultProjectPath: config?.defaultProjectPath || '' })
67
+ await initHistory(dataDir)
68
+ })())
69
+
70
+ ctx.effect(() => {
71
+ const dispose = ctx.webServer.register({
72
+ kind: 'prefix',
73
+ path: ROUTE,
74
+ async handler(req, res) {
75
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
76
+ try {
77
+ await ensure()
78
+ if (req.method === 'GET' && url.pathname === ROUTE_DEPLOY) {
79
+ await handleDeploy(req, res, url)
80
+ return
81
+ }
82
+ if (req.method !== 'POST') {
83
+ send(res, { ok: false, error: '只支持 POST' })
84
+ return
85
+ }
86
+ const body = await readJson(req).catch(() => null)
87
+ send(res, await handleRpc(body))
88
+ } catch (error) {
89
+ if (res.headersSent) {
90
+ try { res.end() } catch {}
91
+ return
92
+ }
93
+ send(res, { ok: false, error: error?.message || '请求失败' })
94
+ }
95
+ },
96
+ })
97
+ return () => {
98
+ dispose()
99
+ killActiveProcess()
100
+ }
101
+ }, 'dsh-app-deploy: routes')
102
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Deployment panel",
4
+ "description": "Orchestrate per-environment Gradle builds, rsync syncs and SSH commands with live deploy logs and run history."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "部署面板",
4
+ "description": "按环境编排并执行 Gradle 构建、rsync 同步与 SSH 远程命令,实时查看部署日志与执行记录。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "dsh-app-deploy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "DSH 插件:按环境编排并执行 Gradle 构建、rsync 同步与 SSH 远程命令的部署面板,实时推送日志并保留执行记录。",
5
+ "repository": "btboys/dsh-app-deploy",
6
+ "type": "module",
7
+ "icon": "./icon.svg",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "client.js",
18
+ "server",
19
+ "cordis.patch.yml",
20
+ "locale",
21
+ "icon.svg",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test",
26
+ "check": "node --check index.js && node --check client.js"
27
+ },
28
+ "engines": {
29
+ "node": ">=22",
30
+ "dsh": ">=0.2.1-rc.3 <0.3.0"
31
+ },
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true,
39
+ "inject": []
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,212 @@
1
+ import { promises as fsp } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // ---------- 状态:数据目录由插件 Config/dataDir 决定,initConfig 时绑定 ----------
5
+ let dataDir = ''
6
+ let envsFile = ''
7
+ let defaultProjectPath = ''
8
+ let envs = []
9
+
10
+ // 固定 4 个模块的显示名与旧 record 键的映射
11
+ const MODULE_NAME = { bsFront: 'bs 前端', bsServer: 'bs 服务端', adminFront: 'admin 前端', adminServer: 'admin 服务端' }
12
+ const MODULE_ORDER = ['bsFront', 'bsServer', 'adminFront', 'adminServer']
13
+
14
+ function defaultModules() {
15
+ return [
16
+ { id: 'bsFront', name: 'bs 前端', kind: 'front', enabled: true, gradleTask: 'client:client-front:dist', key: '', extraArgs: '', localPath: 'client/client-front/dist/', remotePath: '', postCommand: '', frontEnv: '' },
17
+ { id: 'bsServer', name: 'bs 服务端', kind: 'server', enabled: true, gradleTask: 'client:client-server:bootJar', key: '', extraArgs: '', localPath: 'client/client-server/build/libs/financial-v5.jar', remotePath: '', postCommand: '', frontEnv: '' },
18
+ { id: 'adminFront', name: 'admin 前端', kind: 'front', enabled: true, gradleTask: 'admin:admin-front:dist', key: '', extraArgs: '', localPath: 'admin/admin-front/dist/', remotePath: '', postCommand: '', frontEnv: '' },
19
+ { id: 'adminServer', name: 'admin 服务端', kind: 'server', enabled: true, gradleTask: 'admin:admin-server:bootJar', key: '', extraArgs: '', localPath: 'admin/admin-server/build/libs/financial-admin-v5.jar', remotePath: '', postCommand: '', frontEnv: '' },
20
+ ]
21
+ }
22
+
23
+ function templateEnv() {
24
+ return { id: '', name: '', note: '', group: '', projectPath: defaultProjectPath, jdkHome: '', expTime: '', ssh: { host: '', port: 22, user: 'root', password: '', options: '' }, modules: defaultModules() }
25
+ }
26
+
27
+ // 把 modules 归一化为数组;record 旧格式自动转数组(id 保持 bsFront 等,name 映射),frontEnv.bs/admin 迁移进对应 front 模块
28
+ function isNewSchema(e) {
29
+ return !!e && typeof e === 'object' && !!e.ssh && typeof e.ssh === 'object'
30
+ }
31
+
32
+ // 把 modules 归一化为数组;record 旧格式自动转数组(id 保持 bsFront 等,name 映射),frontEnv.bs/admin 迁移进对应 front 模块
33
+ function toModArray(mods, fe) {
34
+ const out = []
35
+ if (Array.isArray(mods)) {
36
+ let i = 0
37
+ for (const raw of mods) {
38
+ const id = String(raw?.id || `mod${++i}`)
39
+ out.push({
40
+ id,
41
+ name: String(raw?.name || MODULE_NAME[id] || id),
42
+ kind: (raw?.kind === 'server' ? 'server' : raw?.kind === 'front' ? 'front' : (id.endsWith('Front') ? 'front' : 'server')),
43
+ enabled: raw?.enabled !== false,
44
+ gradleTask: String(raw?.gradleTask || ''),
45
+ key: String(raw?.key || ''),
46
+ extraArgs: String(raw?.extraArgs || ''),
47
+ localPath: String(raw?.localPath || ''),
48
+ remotePath: String(raw?.remotePath || ''),
49
+ postCommand: String(raw?.postCommand || ''),
50
+ healthCheckUrl: String(raw?.healthCheckUrl || '').trim(),
51
+ frontEnv: String(raw?.frontEnv || ''),
52
+ })
53
+ }
54
+ } else if (mods && typeof mods === 'object') {
55
+ for (const mk of MODULE_ORDER) {
56
+ const m = mods[mk]
57
+ if (!m) continue
58
+ const feText = mk === 'bsFront' ? (fe?.bs || '') : mk === 'adminFront' ? (fe?.admin || '') : ''
59
+ out.push({
60
+ id: mk, name: MODULE_NAME[mk] || mk, kind: mk.endsWith('Front') ? 'front' : 'server',
61
+ enabled: m.enabled !== false,
62
+ gradleTask: String(m.gradleTask || ''), key: String(m.key || ''), extraArgs: String(m.extraArgs || ''),
63
+ localPath: String(m.localPath || ''), remotePath: String(m.remotePath || ''), postCommand: String(m.postCommand || ''),
64
+ frontEnv: feText,
65
+ })
66
+ }
67
+ }
68
+ return out
69
+ }
70
+
71
+ function normalizeEnv(e) {
72
+ const env = {
73
+ id: e.id, name: e.name, note: e.note ?? '',
74
+ group: e.group ?? '',
75
+ projectPath: e.projectPath || defaultProjectPath,
76
+ jdkHome: e.jdkHome ?? '',
77
+ expTime: e.expTime ?? '',
78
+ ssh: { host: e.ssh?.host ?? '', port: Number(e.ssh?.port) || 22, user: e.ssh?.user ?? 'root', password: e.ssh?.password ?? '', options: e.ssh?.options ?? '' },
79
+ modules: toModArray(e.modules, { bs: e.frontEnv?.bs, admin: e.frontEnv?.admin }),
80
+ }
81
+ // 迁移(幂等):把 server 模块 extraArgs 里的 -PexpTime=xxx 提取到 env.expTime 并从 extraArgs 移除
82
+ let extracted = ''
83
+ for (const m of env.modules) {
84
+ if (m.kind !== 'server' || !m.extraArgs) continue
85
+ const kept = m.extraArgs.trim().split(/\s+/).filter(p => {
86
+ if (p.startsWith('-PexpTime=')) { if (!extracted) extracted = p.slice(10); return false }
87
+ return true
88
+ })
89
+ m.extraArgs = kept.join(' ')
90
+ }
91
+ if (extracted && !env.expTime) env.expTime = extracted
92
+ return env
93
+ }
94
+
95
+ async function loadEnvs() {
96
+ let raw = null
97
+ try { raw = JSON.parse(await fsp.readFile(envsFile, 'utf8')) } catch {}
98
+ // 不播种:文件缺失/损坏时初始化为空配置
99
+ if (!Array.isArray(raw)) {
100
+ await fsp.writeFile(envsFile, '[]')
101
+ return []
102
+ }
103
+ let migrated = false
104
+ const out = []
105
+ for (const e of raw) {
106
+ if (isNewSchema(e)) {
107
+ const r = e
108
+ if (!Array.isArray(r.modules) || r.modules.some(m => m?.extraArgs?.toString().includes('-PexpTime='))) migrated = true
109
+ out.push(normalizeEnv(r))
110
+ continue
111
+ }
112
+ migrated = true
113
+ // 旧 schema({id,name,script,...}):保留 id/name/note,其余字段用空模板兜底
114
+ const old = e
115
+ const base = templateEnv()
116
+ out.push(normalizeEnv({ ...base, id: String(old.id || ''), name: String(old.name || base.name), note: String(old.note ?? base.note ?? '') }))
117
+ }
118
+ if (migrated) await fsp.writeFile(envsFile, JSON.stringify(out, null, 2))
119
+ return out
120
+ }
121
+
122
+ export async function initConfig({ dataDir: dir, defaultProjectPath: projectPath = '' } = {}) {
123
+ dataDir = dir || ''
124
+ envsFile = dataDir ? path.join(dataDir, 'envs.json') : ''
125
+ defaultProjectPath = projectPath || ''
126
+ if (!envsFile) throw new Error('未配置数据目录')
127
+ envs = await loadEnvs()
128
+ }
129
+
130
+ export function getEnvs() { return envs }
131
+
132
+ export async function saveEnvs(next = envs) {
133
+ await fsp.writeFile(envsFile, JSON.stringify(next, null, 2))
134
+ envs = next
135
+ }
136
+
137
+ export function getDefaultProjectPath() { return defaultProjectPath }
138
+
139
+ // ---------- 校验 ----------
140
+ const BAD_PATH = /[\s;"'`$&|<>()*?[\]{}!]/ // 禁止空格/引号/; & | ` $ 等
141
+ // 绝对路径:posix 以 / 开头,Windows 为盘符 + \ 或 /(如 C:\jdk、D:/proj)
142
+ const isAbsPath = (p) => /^(\/|[a-zA-Z]:[\\/])/.test(p)
143
+ const BAD_CMD = /["'`;&|<>$()*?[\]{}!]/ // 允许空格,其余同上
144
+
145
+ // 前端 .env 文本:只允许 KEY=VALUE(KEY 为 A-Z0-9_)、空行、# 注释
146
+ export function validateFrontEnv(s) {
147
+ for (const line of s.split('\n')) {
148
+ const t = line.trim()
149
+ if (!t || t.startsWith('#')) continue
150
+ if (!/^[A-Z0-9_]+\s*=/.test(t)) return '前端 .env 只允许 KEY=VALUE 行(KEY 为 A-Z0-9_)或空行/注释行'
151
+ }
152
+ return ''
153
+ }
154
+
155
+ // 前端 .env 文件路径(仅种子 bsFront/adminFront 有约定路径,供「配置为空时读文件作参考」)
156
+ export function frontEnvFile(e, m) {
157
+ const p = (e.projectPath || defaultProjectPath || '').replace(/[\\/]+$/, '')
158
+ if (!p) return null
159
+ const key = m.key ? '.' + m.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}`)
162
+ return null
163
+ }
164
+
165
+ export function validateEnv(e) {
166
+ const errs = []
167
+ if (!/^[a-z0-9-]+$/.test(e.id)) errs.push('ID 只能是小写字母/数字/连字符')
168
+ if (!e.name.trim()) errs.push('名称必填')
169
+ if (e.group && !/^[\u4e00-\u9fa5A-Za-z0-9 -]{1,20}$/.test(e.group)) errs.push('分组只能含中文/字母/数字/空格/连字符,最长 20 字符')
170
+ if (!isAbsPath(e.projectPath)) errs.push('项目路径必须是绝对路径(如 /opt/proj 或 C:\\proj)')
171
+ if (BAD_PATH.test(e.projectPath)) errs.push('项目路径含非法字符(禁止空格/引号/; & | ` $ 等)')
172
+ if (e.jdkHome && !isAbsPath(e.jdkHome)) errs.push('JDK 路径必须是绝对路径')
173
+ if (e.jdkHome && BAD_PATH.test(e.jdkHome)) errs.push('JDK 路径含非法字符')
174
+ const s = e.ssh
175
+ if (!/^[a-zA-Z0-9.-]+$/.test(s.host)) errs.push('SSH host 非法(仅字母/数字/./-)')
176
+ if (!Number.isInteger(s.port) || s.port < 1 || s.port > 65535) errs.push('SSH port 必须是 1-65535 的整数')
177
+ if (!/^[a-zA-Z0-9._-]+$/.test(s.user)) errs.push('SSH user 非法')
178
+ if (BAD_CMD.test(s.options)) errs.push('SSH 自定义选项含非法字符')
179
+ if (s.password.includes('\n')) errs.push('SSH 密码不能包含换行')
180
+ if (e.expTime && !/^\d{4}-\d{2}-\d{2}$/.test(e.expTime)) errs.push('过期时间必须是 YYYY-MM-DD')
181
+ if (!Array.isArray(e.modules) || !e.modules.length) errs.push('至少需要一个模块')
182
+ const seenId = {}
183
+ for (const m of (e.modules || [])) {
184
+ if (!/^[A-Za-z0-9-]+$/.test(m.id)) errs.push(`模块 ${m.name || m.id}: ID「${m.id}」只能包含字母/数字/连字符`)
185
+ else if (seenId[m.id]) errs.push(`模块 ${m.name || m.id}: ID「${m.id}」重复(环境内须唯一)`)
186
+ seenId[m.id] = true
187
+ if (!m.name.trim()) errs.push('模块显示名必填')
188
+ if (m.kind !== 'front' && m.kind !== 'server') errs.push(`模块 ${m.name || m.id}: kind 必须是 front 或 server`)
189
+ if (m.kind === 'front') {
190
+ const fe = validateFrontEnv(m.frontEnv || '')
191
+ if (fe) errs.push(`${m.name || m.id}: ${fe}`)
192
+ }
193
+ if (m.healthCheckUrl) {
194
+ try {
195
+ const url = new URL(m.healthCheckUrl)
196
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('invalid URL')
197
+ } catch { errs.push(`${m.name || m.id}: 健康检查 URL 必须是 HTTP/HTTPS 地址,且不能包含用户名或密码`) }
198
+ }
199
+ if (!m.enabled) continue
200
+ if (!/^[a-zA-Z0-9:_-]+$/.test(m.gradleTask)) errs.push(`${m.name || m.id}: gradle 任务非法`)
201
+ if (m.key && !/^[a-zA-Z0-9._-]+$/.test(m.key)) errs.push(`${m.name || m.id}: -Pkey 非法`)
202
+ if (m.extraArgs && BAD_CMD.test(m.extraArgs)) errs.push(`${m.name || m.id}: 额外参数含非法字符`)
203
+ if (!m.localPath) errs.push(`${m.name || m.id}: 本地产物路径必填`)
204
+ else if (BAD_PATH.test(m.localPath)) errs.push(`${m.name || m.id}: 本地产物路径含非法字符`)
205
+ if (!m.remotePath) errs.push(`${m.name || m.id}: 远程目标路径必填`)
206
+ else if (BAD_PATH.test(m.remotePath)) errs.push(`${m.name || m.id}: 远程目标路径含非法字符(禁止空格/引号/; & | \` $ 等)`)
207
+ if (m.postCommand && BAD_CMD.test(m.postCommand)) errs.push(`${m.name || m.id}: 远程命令含非法字符`)
208
+ }
209
+ return errs
210
+ }
211
+
212
+ export { MODULE_NAME, MODULE_ORDER, templateEnv, isNewSchema, normalizeEnv }