@gehennawu/dsh-service 1.9.8 → 2.0.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.en.md +32 -7
- package/README.md +32 -7
- package/backup-integrity.js +52 -4
- package/backup-routes.js +5 -1
- package/client.js +1 -1
- package/index.js +135 -21
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -123,7 +123,25 @@ const MAX_BACKUP_TRANSFER_BYTES = 256 * 1024 * 1024
|
|
|
123
123
|
const MAX_BACKUP_COMPRESSED_BYTES = 512 * 1024 * 1024
|
|
124
124
|
const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
125
125
|
const backupIdSecret = randomBytes(32)
|
|
126
|
-
|
|
126
|
+
// 归档命名:`dsh-backup-<UTC 时间戳>[-dsh<备份时的 DSH 版本>].tar.gz`。版本段是可选的,
|
|
127
|
+
// 老版本插件所出的归档(无版本段)与「版本号读不出来」两种情形共用同一条无版本写法,
|
|
128
|
+
// 因此正则必须同时接受两形——否则旧归档会从列表里消失、导入也会被当成非法文件名。
|
|
129
|
+
// 版本段 = 至少含一个数字的 semver 形串(`0.1.7-rc.1` 一类),长度封顶 64、首字符为
|
|
130
|
+
// 字母或数字:既避免 `..` 之类的路径把戏,也把「读不出运行版本」的哨兵值 `unknown`
|
|
131
|
+
// 挡在门外——少了「必须含数字」这一条,未解析出 DSH 包的宿主会写出 `-dshunknown`。
|
|
132
|
+
const BACKUP_VERSION_RE = /^(?=.*[0-9])[0-9A-Za-z][0-9A-Za-z.-]{0,63}$/
|
|
133
|
+
// BACKUP_NAME 的版本捕获组必须与 BACKUP_VERSION_RE 同源:两处各写一遍,迟早出现
|
|
134
|
+
// 「写得出的名字解析不回来」。由 .source 派生,单一事实源。
|
|
135
|
+
const BACKUP_VERSION_PATTERN = BACKUP_VERSION_RE.source.slice(1, -1)
|
|
136
|
+
// 版本段后必须紧跟扩展名:`(?=...)` 里的「必须含数字」同理不能丢。
|
|
137
|
+
const BACKUP_NAME = new RegExp(`^dsh-backup-\\d{8}-\\d{6}(?:-dsh(${BACKUP_VERSION_PATTERN}))?\\.tar\\.gz$`)
|
|
138
|
+
// 归档内元数据:唯一权威的「备份时」事实。文件名里的版本段是它的廉价镜像,供列表
|
|
139
|
+
// 零成本展示(列表只 stat 文件,绝不为了显示版本号去解压每个归档)。
|
|
140
|
+
// 路径常量与备份完整性侧(backup-integrity.js 的 META_PATH)必须逐字一致,否则
|
|
141
|
+
// 「自己写的归档自己不认」——两边各有测试钉住同一串。
|
|
142
|
+
const BACKUP_META_DIR = 'meta'
|
|
143
|
+
const BACKUP_META_FILE = 'backup.json'
|
|
144
|
+
const BACKUP_META_VERSION = 1
|
|
127
145
|
const USAGE_INDEX_VERSION = 7
|
|
128
146
|
const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
|
|
129
147
|
// 桶口径已对齐(= 当前折叠口径)的最低索引版本:口径在 v5 定稿,v5/v6 的折叠函数与当前
|
|
@@ -528,6 +546,40 @@ function formatBackupTimestamp(date) {
|
|
|
528
546
|
return `${date.getUTCFullYear()}${digits(date.getUTCMonth() + 1)}${digits(date.getUTCDate())}-${digits(date.getUTCHours())}${digits(date.getUTCMinutes())}${digits(date.getUTCSeconds())}`
|
|
529
547
|
}
|
|
530
548
|
|
|
549
|
+
// 文件名里的版本段:只有通过字符集校验的版本号才写进文件名,读不出来('unknown'、空串、
|
|
550
|
+
// 老宿主未安装包)时静默省略——宁可没有这段,也不要让一个非法字符把备份文件名变成垃圾。
|
|
551
|
+
function backupNameVersionSuffix(version) {
|
|
552
|
+
return typeof version === 'string' && BACKUP_VERSION_RE.test(version) ? `-dsh${version}` : ''
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 归档内元数据(`meta/backup.json`):恢复预检据此显示「这份备份出在哪个 DSH 版本」,
|
|
556
|
+
// 与文件名里的版本段互为印证。内容全部来自宿主常量与运行版本,浏览器不参与任何字段。
|
|
557
|
+
function backupMetadataPayload(version, createdAt) {
|
|
558
|
+
return Buffer.from(`${JSON.stringify({
|
|
559
|
+
version: BACKUP_META_VERSION,
|
|
560
|
+
dshVersion: typeof version === 'string' ? version : '',
|
|
561
|
+
pluginVersion: typeof pluginVersion === 'string' ? pluginVersion : '',
|
|
562
|
+
createdAt: createdAt instanceof Date ? createdAt.toISOString() : new Date().toISOString(),
|
|
563
|
+
}, null, 2)}\n`, 'utf8')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 从归档名解析版本段(列表展示用,零 I/O)。只有 Host 自己签发的名字会走到这里,
|
|
567
|
+
// 但正则本身也保证不会把任意文本当版本号回给浏览器。
|
|
568
|
+
function backupVersionFromName(name) {
|
|
569
|
+
const match = BACKUP_NAME.exec(name)
|
|
570
|
+
return match === null || match[1] === undefined ? null : match[1]
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 归档创建时刻:从文件名的时间戳段反解,保证「文件名说的时间」与「元数据说的时间」
|
|
574
|
+
// 永远是同一个瞬间(各自取一次 `new Date()` 会在跨秒时打架)。
|
|
575
|
+
function backupCreatedAtFromName(name) {
|
|
576
|
+
const match = /^dsh-backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/.exec(name)
|
|
577
|
+
if (match === null) return new Date().toISOString()
|
|
578
|
+
const [, year, month, day, hour, minute, second] = match
|
|
579
|
+
const parsed = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`)
|
|
580
|
+
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString()
|
|
581
|
+
}
|
|
582
|
+
|
|
531
583
|
function backupId(name) {
|
|
532
584
|
return createHmac('sha256', backupIdSecret).update(name).digest('base64url')
|
|
533
585
|
}
|
|
@@ -547,7 +599,9 @@ async function backupItemForId(dshHome, id) {
|
|
|
547
599
|
const snapshot = await listBackups(dshHome)
|
|
548
600
|
const item = snapshot.items.find((candidate) => candidate.id === id)
|
|
549
601
|
if (item === undefined) return undefined
|
|
550
|
-
|
|
602
|
+
// nameVersion 随 source 下传完整性检查:归档内查不到 meta/backup.json 时(旧插件所出),
|
|
603
|
+
// 报告退回文件名里的版本段,界面上仍有「这份备份出在哪个 DSH 版本」可看。
|
|
604
|
+
return { ...item, nameVersion: backupVersionFromName(item.name), path: join(dshHome, 'backups', basename(item.name)), mtimeMs: Date.parse(item.createdAt) }
|
|
551
605
|
}
|
|
552
606
|
|
|
553
607
|
async function listBackups(dshHome) {
|
|
@@ -558,11 +612,15 @@ async function listBackups(dshHome) {
|
|
|
558
612
|
for (const entry of entries) {
|
|
559
613
|
if (!entry.isFile() || !BACKUP_NAME.test(entry.name)) continue
|
|
560
614
|
const info = await stat(join(backupDir, entry.name))
|
|
615
|
+
const nameVersion = backupVersionFromName(entry.name)
|
|
561
616
|
items.push({
|
|
562
617
|
id: backupId(entry.name),
|
|
563
618
|
name: entry.name,
|
|
564
619
|
sizeBytes: info.size,
|
|
565
620
|
createdAt: info.mtime.toISOString(),
|
|
621
|
+
// 旧归档(无版本段)与老版本插件所出归档回 null:客户端据此退回中性文案,
|
|
622
|
+
// 不编造版本、也不显示「未知版本」这种噪声。
|
|
623
|
+
dshVersion: nameVersion,
|
|
566
624
|
})
|
|
567
625
|
}
|
|
568
626
|
items.sort((a, b) => b.name.localeCompare(a.name))
|
|
@@ -967,6 +1025,10 @@ async function createBackupAttempt(ctx, dshHome, backupDir, name, validateArchiv
|
|
|
967
1025
|
// 每个 profile 的常驻常规文件:0.1.7-alpha.1 起 `cordis.patch.yml` 是 Profile 的用户配置层
|
|
968
1026
|
// (bundle 之上、settings 的真正落点),只带 package.json 会「备份成功但配置全丢」。
|
|
969
1027
|
const profileNames = ['package.json', 'cordis.patch.yml']
|
|
1028
|
+
// 备份时版本信息(归档 `meta/backup.json`)先落定载荷:它要计入总量口径,
|
|
1029
|
+
// 才不至于让复制阶段出现「已复制 > 总量」的假象。
|
|
1030
|
+
const metaPayload = backupMetadataPayload(dshVersion, backupCreatedAtFromName(name))
|
|
1031
|
+
const metaBytes = metaPayload.length
|
|
970
1032
|
let configBytes = 0
|
|
971
1033
|
for (const file of configNames) configBytes += await sumBackupTree(join(dshHome, file))
|
|
972
1034
|
let profilesBytes = 0
|
|
@@ -997,7 +1059,7 @@ async function createBackupAttempt(ctx, dshHome, backupDir, name, validateArchiv
|
|
|
997
1059
|
try { const info = await lstat(entry.path); if (info.isFile()) sessionsBytes += info.size } catch (_) {}
|
|
998
1060
|
}
|
|
999
1061
|
} else if (await pathExists(sessionsSource)) sessionsBytes = await sumBackupTree(sessionsSource)
|
|
1000
|
-
const totalBytes = sessionsBytes + configBytes + profilesBytes
|
|
1062
|
+
const totalBytes = sessionsBytes + configBytes + profilesBytes + metaBytes
|
|
1001
1063
|
let copiedBytes = 0
|
|
1002
1064
|
// 快照形状恒定:任何阶段都带全部字段,客户端轮询无需按阶段判形。
|
|
1003
1065
|
const report = (phase, archiveBytes) => onProgress({ phase, copiedBytes, totalBytes, archiveBytes })
|
|
@@ -1056,6 +1118,12 @@ async function createBackupAttempt(ctx, dshHome, backupDir, name, validateArchiv
|
|
|
1056
1118
|
}
|
|
1057
1119
|
|
|
1058
1120
|
await assertSafeBackupTree(workspace)
|
|
1121
|
+
// 备份时版本信息:写进归档 `meta/backup.json`,与文件名里的版本段互为印证。
|
|
1122
|
+
// 放在暂存树与打包之间——先写后打,tar 的条目集合天然包含它;字节计入复制阶段
|
|
1123
|
+
// (`copiedBytes` 是「已复制」而非「已从源读取」的诚实口径,进度条不会超 100%)。
|
|
1124
|
+
await mkdir(join(workspace, BACKUP_META_DIR), { recursive: true, mode: 0o700 })
|
|
1125
|
+
await writeFile(join(workspace, BACKUP_META_DIR, BACKUP_META_FILE), metaPayload, { mode: 0o600 })
|
|
1126
|
+
onCopied(metaPayload.length)
|
|
1059
1127
|
onProgress({ phase: 'archive', copiedBytes, totalBytes, archiveBytes: 0 })
|
|
1060
1128
|
// 打包阶段采样临时归档体积(500ms best-effort):tar 结束即停;停标后完成的采样不再上报,避免阶段回跳。
|
|
1061
1129
|
let sampling = false
|
|
@@ -1073,7 +1141,7 @@ async function createBackupAttempt(ctx, dshHome, backupDir, name, validateArchiv
|
|
|
1073
1141
|
} catch (_) {}
|
|
1074
1142
|
}
|
|
1075
1143
|
})()
|
|
1076
|
-
const volatileSource = await runTar(ctx, workspace, ['-czf', temporary, 'sessions', 'config', 'profiles'])
|
|
1144
|
+
const volatileSource = await runTar(ctx, workspace, ['-czf', temporary, 'sessions', 'config', 'profiles', 'meta'])
|
|
1077
1145
|
// 退出码 1 只说明 tar 认为某个文件在读取期间变化过,不能据此判定归档好坏:
|
|
1078
1146
|
// 拿归档回头核对暂存树,逐条目全等才继续,任何差异都判为不完整并拒绝发布。
|
|
1079
1147
|
if (volatileSource) {
|
|
@@ -1144,6 +1212,9 @@ async function exportBackup(dshHome, downloadTokens, id) {
|
|
|
1144
1212
|
return { name: item.name, url: `/dsh-backup-download?token=${token}` }
|
|
1145
1213
|
}
|
|
1146
1214
|
|
|
1215
|
+
// 备份归档的文件名在创建时即固定为「时间戳 + 备份时的 DSH 版本」,导入侧只做文件名
|
|
1216
|
+
// 白名单校验(版本段可选,见 BACKUP_NAME):导入是「把用户手里的归档原样放回 backups 目录」,
|
|
1217
|
+
// 版本事实以归档内的 meta/backup.json 为准,不改名、不重写。
|
|
1147
1218
|
async function importBackup(dshHome, name, encoded, validatePath) {
|
|
1148
1219
|
if (typeof name !== 'string' || !BACKUP_NAME.test(name) || typeof encoded !== 'string' || encoded.length === 0) return undefined
|
|
1149
1220
|
if (encoded.length > Math.ceil(MAX_BACKUP_TRANSFER_BYTES / 3) * 4 + 8 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) return undefined
|
|
@@ -2968,9 +3039,11 @@ async function repairPermissions(ctx, dshHome, plans, planId) {
|
|
|
2968
3039
|
|
|
2969
3040
|
// 运行环境检查(v0.17):managed/declared → ok;疑似手动启动 → warning 但带 advisory 标记
|
|
2970
3041
|
//(黄色行内提示,不参与 overall 聚合、不点亮标签 ⚠ 与顶部提醒——用户复核口径);unknown →
|
|
2971
|
-
// info。detail
|
|
3042
|
+
// info。detail 是客户端映射词典的令牌。桌面端(Electron)由应用自身托管重启,是明确的 ok
|
|
3043
|
+
// 事实,不再落 unknown 的含糊提示,且优先于 manual 判定。
|
|
2972
3044
|
function runtimeEnvCheck(runtimeEnv) {
|
|
2973
3045
|
if (runtimeEnv === undefined || runtimeEnv === null) return null
|
|
3046
|
+
if (runtimeEnv.electronShell === true) return { id: 'runtime-env', status: 'ok', detail: 'desktop' }
|
|
2974
3047
|
if (runtimeEnv.manualStartLikely === true) return { id: 'runtime-env', status: 'warning', detail: 'manual', advisory: true }
|
|
2975
3048
|
if (runtimeEnv.supervisorKind === 'declared') return { id: 'runtime-env', status: 'ok', detail: 'declared' }
|
|
2976
3049
|
if (typeof runtimeEnv.supervisorKind === 'string' && runtimeEnv.supervisorKind.length > 0) {
|
|
@@ -3185,7 +3258,9 @@ function collectActiveWork(ctx) {
|
|
|
3185
3258
|
// manualStartLikely=true —— 无管理器且 stdin/stdout 双 TTY(终端直启特征),升级后宿主
|
|
3186
3259
|
// 不自动退出,客户端改示手动重启指引;
|
|
3187
3260
|
// 其余 —— unknown(输出重定向、NSSM/WinSW 等包装器),维持现状不折腾。
|
|
3188
|
-
// 探测不到的包装器可用 DSH_SERVICE_RUNTIME_ENV=managed|manual 由用户显式声明。
|
|
3261
|
+
// 探测不到的包装器可用 DSH_SERVICE_RUNTIME_ENV=managed|manual|desktop 由用户显式声明。
|
|
3262
|
+
// 第四态 electronShell=true 是正交的平台事实(DSH 桌面端),三态取值不变:桌面端既没有
|
|
3263
|
+
// supervisor 标记、stdio 也非 TTY,本会落 unknown,靠这一位才能改走桌面端重启引导。
|
|
3189
3264
|
const RUNTIME_SUPERVISOR_ENV = [
|
|
3190
3265
|
['pm2', ['pm_id', 'PM2_HOME', 'pm_uptime']],
|
|
3191
3266
|
['systemd', ['INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET']],
|
|
@@ -3193,9 +3268,21 @@ const RUNTIME_SUPERVISOR_ENV = [
|
|
|
3193
3268
|
['kubernetes', ['KUBERNETES_SERVICE_HOST']],
|
|
3194
3269
|
]
|
|
3195
3270
|
|
|
3271
|
+
// 桌面端(DSH Desktop)判据:桌面端的 Host 是 Electron 以 RunAsNode 承载的子进程,该进程里
|
|
3272
|
+
// `process.versions.electron` 有值(2026-09-28 Windows 真机实测:electron 44.0.0 / node 24.18.1,
|
|
3273
|
+
// 同进程 ELECTRON_RUN_AS_NODE=1,stdin/stdout 均非 TTY)。只认版本号字段:它是进程自身的运行时
|
|
3274
|
+
// 事实,不像 env 那样会随父进程继承给普通子进程,也不会对 iframe/嵌入帧误判(`'dshDesktop' in
|
|
3275
|
+
// globalThis` 在嵌入帧里同样成立,不能当判据)。非 Electron 的桌面封装可显式声明
|
|
3276
|
+
// DSH_SERVICE_RUNTIME_ENV=desktop。
|
|
3277
|
+
function isElectronRuntime(versions) {
|
|
3278
|
+
return versions !== null && typeof versions === 'object'
|
|
3279
|
+
&& typeof versions.electron === 'string' && versions.electron.length > 0
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3196
3282
|
function detectRuntimeEnv(options = {}) {
|
|
3197
3283
|
const env = options.env ?? process.env
|
|
3198
3284
|
const platform = options.platform ?? process.platform
|
|
3285
|
+
const versions = options.versions ?? process.versions
|
|
3199
3286
|
const stdinIsTTY = options.stdinIsTTY ?? (process.stdin ? process.stdin.isTTY === true : false)
|
|
3200
3287
|
const stdoutIsTTY = options.stdoutIsTTY ?? (process.stdout ? process.stdout.isTTY === true : false)
|
|
3201
3288
|
// 默认探测器只在未被注入时执行;win32 直接短路(/.dockerenv 与 /proc 不存在),单测全部显式传原语。
|
|
@@ -3203,25 +3290,39 @@ function detectRuntimeEnv(options = {}) {
|
|
|
3203
3290
|
const cgroupText = options.cgroupText ?? (platform === 'win32' ? '' : (() => { try { return readFileSync('/proc/1/cgroup', 'utf8') } catch (_) { return '' } })())
|
|
3204
3291
|
|
|
3205
3292
|
const forced = typeof env.DSH_SERVICE_RUNTIME_ENV === 'string' ? env.DSH_SERVICE_RUNTIME_ENV.trim().toLowerCase() : ''
|
|
3206
|
-
|
|
3207
|
-
|
|
3293
|
+
const electronShell = forced === 'desktop' || isElectronRuntime(versions)
|
|
3294
|
+
// 桌面端标记只在成立时出现在返回对象里:其余平台的原返回逐字不变,既有三态用例与下游
|
|
3295
|
+
// deepEqual 断言不受影响。判据只喂给「重启 / 升级 / 诊断」的用户可见行为,不作安全门槛。
|
|
3296
|
+
const shell = (result) => (electronShell ? { ...result, electronShell: true } : result)
|
|
3297
|
+
|
|
3298
|
+
if (forced === 'manual') return shell({ platform, supervisorKind: null, manualStartLikely: true })
|
|
3299
|
+
if (forced === 'managed') return shell({ platform, supervisorKind: 'declared', manualStartLikely: false })
|
|
3300
|
+
// 显式声明桌面端:不再往下看 supervisor 标记与 TTY(桌面端那些信号都不代表会被拉起)。
|
|
3301
|
+
if (forced === 'desktop') return shell({ platform, supervisorKind: null, manualStartLikely: false })
|
|
3208
3302
|
|
|
3209
3303
|
for (const [kind, keys] of RUNTIME_SUPERVISOR_ENV) {
|
|
3210
3304
|
if (keys.some((key) => env[key] !== undefined && env[key] !== '')) {
|
|
3211
|
-
return { platform, supervisorKind: kind, manualStartLikely: false }
|
|
3305
|
+
return shell({ platform, supervisorKind: kind, manualStartLikely: false })
|
|
3212
3306
|
}
|
|
3213
3307
|
}
|
|
3214
3308
|
// /.dockerenv 与 /proc 只在 POSIX 内核上存在;win32 原生进程不做这两个探测。
|
|
3215
3309
|
if (platform !== 'win32') {
|
|
3216
|
-
if (dockerEnvExists === true) return { platform, supervisorKind: 'docker', manualStartLikely: false }
|
|
3217
|
-
if (/docker/i.test(cgroupText)) return { platform, supervisorKind: 'docker', manualStartLikely: false }
|
|
3218
|
-
if (/containerd|kubepods|lxc|podman/i.test(cgroupText)) return { platform, supervisorKind: 'container', manualStartLikely: false }
|
|
3310
|
+
if (dockerEnvExists === true) return shell({ platform, supervisorKind: 'docker', manualStartLikely: false })
|
|
3311
|
+
if (/docker/i.test(cgroupText)) return shell({ platform, supervisorKind: 'docker', manualStartLikely: false })
|
|
3312
|
+
if (/containerd|kubepods|lxc|podman/i.test(cgroupText)) return shell({ platform, supervisorKind: 'container', manualStartLikely: false })
|
|
3219
3313
|
}
|
|
3220
|
-
if (stdinIsTTY === true && stdoutIsTTY === true) return { platform, supervisorKind: null, manualStartLikely: true }
|
|
3221
|
-
return { platform, supervisorKind: null, manualStartLikely: false }
|
|
3314
|
+
if (stdinIsTTY === true && stdoutIsTTY === true) return shell({ platform, supervisorKind: null, manualStartLikely: true })
|
|
3315
|
+
return shell({ platform, supervisorKind: null, manualStartLikely: false })
|
|
3222
3316
|
}
|
|
3223
3317
|
|
|
3224
|
-
|
|
3318
|
+
// 桌面端不退出:壳把 Host 的任何非 0 退出判为崩溃,写 crash report 并弹原生「启动失败」对话框
|
|
3319
|
+
// (退出 / 重启 / 禁用第三方插件),没有自动重拉,且官方没有任何可编程重启 RPC。因此桌面端一律
|
|
3320
|
+
// 不 exit(42),把「已是最新状态、需要重启」这一事实交给调用方转述(客户端改示桌面端重启指引)。
|
|
3321
|
+
// 返回值:scheduled=false + desktopManaged=true 时调用方不得再启动恢复轮询(不会出现新实例)。
|
|
3322
|
+
function scheduleRestart(ctx, runtimeEnv) {
|
|
3323
|
+
if (runtimeEnv !== undefined && runtimeEnv !== null && runtimeEnv.electronShell === true) {
|
|
3324
|
+
return { scheduled: false, desktopManaged: true }
|
|
3325
|
+
}
|
|
3225
3326
|
const doExit = () => {
|
|
3226
3327
|
try {
|
|
3227
3328
|
// 退出码 42 交给 Docker/systemd/pm2 的重启策略处理。
|
|
@@ -3231,8 +3332,9 @@ function scheduleRestart(ctx) {
|
|
|
3231
3332
|
}
|
|
3232
3333
|
}
|
|
3233
3334
|
const timer = ctx.get('timer')
|
|
3234
|
-
if (timer !== undefined)
|
|
3235
|
-
|
|
3335
|
+
if (timer !== undefined) timer.timeout(doExit, 500)
|
|
3336
|
+
else doExit()
|
|
3337
|
+
return { scheduled: true, desktopManaged: false }
|
|
3236
3338
|
}
|
|
3237
3339
|
|
|
3238
3340
|
// ─── 技能管理(v0.22):扫描 / frontmatter 手术 / AI 补全 ─────────────────────
|
|
@@ -5138,7 +5240,7 @@ function apply(ctx, featureConfig) {
|
|
|
5138
5240
|
isEnabled: () => featureEnabled('backupMaintenance'),
|
|
5139
5241
|
runtimeEnv,
|
|
5140
5242
|
previousInstanceId: instanceId,
|
|
5141
|
-
scheduleRestart: () => scheduleRestart(ctx),
|
|
5243
|
+
scheduleRestart: () => scheduleRestart(ctx, runtimeEnv),
|
|
5142
5244
|
})
|
|
5143
5245
|
// 会话管理(v0.35):删除两段式计划(planId → {id, path, bytes}),TTL 过期自动驱逐。
|
|
5144
5246
|
const sessionDeletePlans = new Map()
|
|
@@ -5630,7 +5732,11 @@ function apply(ctx, featureConfig) {
|
|
|
5630
5732
|
if (invocation.rawInput.trim() !== '') return { kind: 'error', text: '/restart does not accept arguments.' }
|
|
5631
5733
|
const activity = collectActiveWork(ctx)
|
|
5632
5734
|
if (activity.hasActive) return { kind: 'error', text: `Restart refused: ${activity.items.length} active item(s) detected. Use the Service Control restart tab to review them.` }
|
|
5633
|
-
scheduleRestart
|
|
5735
|
+
// 桌面端不退出(见 scheduleRestart):命令反馈必须说清「要重启的是应用」,否则用户
|
|
5736
|
+
// 会以为宿主进程会自己回来。
|
|
5737
|
+
if (scheduleRestart(ctx, runtimeEnv).desktopManaged) {
|
|
5738
|
+
return { kind: 'success', text: 'DSH Desktop manages this process: quit and reopen the app to restart it.' }
|
|
5739
|
+
}
|
|
5634
5740
|
return { kind: 'success', text: 'Restart scheduled. The DSH Web process will exit in 0.5 seconds.' }
|
|
5635
5741
|
},
|
|
5636
5742
|
}), 'dsh-service restart command')
|
|
@@ -5814,6 +5920,11 @@ function apply(ctx, featureConfig) {
|
|
|
5814
5920
|
|
|
5815
5921
|
} },
|
|
5816
5922
|
'upgrade': { audit: true, handle: async (payload, rpcEndpoint) => {
|
|
5923
|
+
// 桌面端短路(先于任何 subprocess):桌面端升级动作要 spawn `dsh plugin --profile desktop add`,
|
|
5924
|
+
// 而 bundled runtime 的 bin 不进 Host PATH,且官方明确 CLI 不能启动/改写 profiles/desktop
|
|
5925
|
+
// (该 profile 由 Electron 独占)⇒ 必然失败。官方通道是桌面端自带的插件管理页,直接告诉
|
|
5926
|
+
// 用户走那条路,不产生任何 subprocess 调用。
|
|
5927
|
+
if (runtimeEnv.electronShell === true) return rpcFailure(new Error('desktop-managed-upgrade'))
|
|
5817
5928
|
try {
|
|
5818
5929
|
const value = await upgradePlugin(ctx, dshHome, runtimeEnv)
|
|
5819
5930
|
// 升级落地即作废更新缓存:下一次 check-update 要按新的磁盘版本重算「是否有新版本」,
|
|
@@ -5896,12 +6007,15 @@ function apply(ctx, featureConfig) {
|
|
|
5896
6007
|
return { ok: false, error: 'active-work', value: activity }
|
|
5897
6008
|
}
|
|
5898
6009
|
|
|
5899
|
-
scheduleRestart(ctx)
|
|
6010
|
+
const restart = scheduleRestart(ctx, runtimeEnv)
|
|
5900
6011
|
return {
|
|
5901
6012
|
ok: true,
|
|
5902
6013
|
value: {
|
|
5903
6014
|
// 只下发客户端真正消费的实例 id;重启反馈文案由客户端词典渲染(双语约束)。
|
|
5904
6015
|
instanceId,
|
|
6016
|
+
// 桌面端不退出(见 scheduleRestart):显式告知客户端「不会有新实例」,客户端据此
|
|
6017
|
+
// 改示桌面端重启指引、不启动恢复轮询(与升级落地那条 requiresManualRestart 同语义)。
|
|
6018
|
+
...(restart.desktopManaged ? { requiresManualRestart: true } : {}),
|
|
5905
6019
|
},
|
|
5906
6020
|
}
|
|
5907
6021
|
|
|
@@ -5988,7 +6102,7 @@ function apply(ctx, featureConfig) {
|
|
|
5988
6102
|
createSessionsRoutes({ ctx, dshHome, sessionBytesCache, sessionDeletePlans, sessionTitleCache, sessionTitlesReady, sessionViewCache, SESSIONS_BYTES_MAX_IDS, SESSIONS_DELETE_PLAN_TTL_MS, SESSIONS_VIEW_PAGE_SIZE, listSessionsForManage, loadDeletedSessions, name, resolveSessionBytesForIds, resolveSessionForDelete, rpcFailure, rpcTechnicalFailure, saveDeletedSessions, searchSessionsContent, sessionExists, sessionIsLive, viewSessionPage }),
|
|
5989
6103
|
createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, canonicalResetCardExpiresAt, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
|
|
5990
6104
|
createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
|
|
5991
|
-
createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, setBackupProgress, withBackupLock, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
|
|
6105
|
+
createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, dshVersion, setBackupProgress, withBackupLock, backupNameVersionSuffix, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
|
|
5992
6106
|
])
|
|
5993
6107
|
|
|
5994
6108
|
const dispatchEndpoint = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gehennawu/dsh-service",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build:client": "node scripts/build-client.mjs",
|
|
8
8
|
"check:client": "node scripts/check-client-source.mjs",
|
|
9
9
|
"pretest": "npm run build:client",
|
|
10
|
-
"test": "node --test",
|
|
10
|
+
"test": "node --test test/*.test.js",
|
|
11
11
|
"prepack": "npm run build:client"
|
|
12
12
|
},
|
|
13
13
|
"main": "./index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"homepage": "https://github.com/gehennawu/dsh-service#readme",
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=22",
|
|
56
|
-
"dsh": ">=0.1.1-rc.2 <0.
|
|
56
|
+
"dsh": ">=0.1.1-rc.2 <0.2.0-rc.2"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@deepseek-ai/schemastery": "^3.18.3"
|