dsh-remote-plugin 0.7.1 → 0.7.2
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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +9 -1
- package/index.mjs +10 -0
- package/insights.mjs +73 -0
- package/package.json +3 -2
- package/plugin-center.mjs +118 -21
- package/public/admin.html +3 -0
- package/public/admin.js +1 -0
- package/public/app.js +100 -174
- package/public/desktop/desktop.html +5 -0
- package/public/desktop/desktop.js +71 -5
- package/public/index.html +14 -64
- package/public/insights.css +14 -0
- package/public/insights.js +228 -0
- package/public/plugin-center.css +89 -1
- package/public/plugin-center.js +148 -39
- package/public/plugin.js +7 -1
- package/public/styles.css +0 -93
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -4504,6 +4504,8 @@ async function proxyLegacyRpcWithModernFallback(req, res, url) {
|
|
|
4504
4504
|
|
|
4505
4505
|
async function proxyModernApi(req, res, url) {
|
|
4506
4506
|
if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
|
|
4507
|
+
// dsh-context is a Connection Fetch route with its own JSON contract, not RPC.
|
|
4508
|
+
if (url.pathname === '/api/dsh-context/detail') return false
|
|
4507
4509
|
if (await detectUpstreamApiFlavor() !== 'modern') return false
|
|
4508
4510
|
let raw = ''
|
|
4509
4511
|
try {
|
|
@@ -4624,7 +4626,13 @@ function proxyApi(req, res, url) {
|
|
|
4624
4626
|
res.end()
|
|
4625
4627
|
return
|
|
4626
4628
|
}
|
|
4627
|
-
|
|
4629
|
+
// Credential exchange is restricted to named client endpoints. Administrative
|
|
4630
|
+
// plugin routes must authenticate the master token before reaching DSH.
|
|
4631
|
+
const remoteClientPath = ['/remote/api/command', '/remote/api/command-status', '/remote/admin/api/dsh'].includes(url.pathname)
|
|
4632
|
+
|| (url.pathname === '/remote/api/insights/cost' && req.method === 'GET')
|
|
4633
|
+
|| /^\/remote\/api\/plugins\/(state|details|market|operations|recover)$/.test(url.pathname)
|
|
4634
|
+
const remoteAdminPath = url.pathname.startsWith('/remote/') && !remoteClientPath
|
|
4635
|
+
const ok = remoteAdminPath ? adminAuthorized(req, url) : remoteClientPath ? controlAuthorized(req, url) : authorized(req, url)
|
|
4628
4636
|
touchDevice(req, ok ? {} : { failedAuth: true })
|
|
4629
4637
|
if (!ok) {
|
|
4630
4638
|
authFailures++
|
package/index.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
|
13
13
|
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
import { createPluginCenter, detectProfile } from './plugin-center.mjs'
|
|
16
|
+
import { readCostInsights } from './insights.mjs'
|
|
16
17
|
|
|
17
18
|
export const name = 'dsh-remote'
|
|
18
19
|
export const inject = ['webServer', 'commands', 'agents', 'connection']
|
|
@@ -637,6 +638,15 @@ async function resolveFile(pathname) {
|
|
|
637
638
|
|
|
638
639
|
async function serveStatic(req, res, ctx) {
|
|
639
640
|
const pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
641
|
+
if (pathname === `${MOUNT}/api/insights/cost`) {
|
|
642
|
+
if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, code: 'unauthorized' })
|
|
643
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, code: 'read-only' })
|
|
644
|
+
const query = new URL(req.url, 'http://x').searchParams
|
|
645
|
+
if ([...query.keys()].some(key => key !== 'sessionId') || query.getAll('sessionId').length > 1) return sendJson(res, 400, { ok: false, code: 'invalid-query' })
|
|
646
|
+
const result = await readCostInsights(ctx, query.get('sessionId') || '')
|
|
647
|
+
res.setHeader('cache-control', 'no-store')
|
|
648
|
+
return sendJson(res, result.status, result.body)
|
|
649
|
+
}
|
|
640
650
|
if (pathname.startsWith(`${MOUNT}/api/plugins/`)) {
|
|
641
651
|
if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, message: 'unauthorized' })
|
|
642
652
|
try {
|
package/insights.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Read-only, bounded adapter for dsh-cost-meter 1.8.x. Never forward its config.
|
|
2
|
+
const states = new WeakMap()
|
|
3
|
+
const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
4
|
+
const number = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
|
5
|
+
const text = (value, max = 80) => typeof value === 'string' ? value.slice(0, max) : ''
|
|
6
|
+
const status = value => ['ok', 'off', 'error', 'stale', 'loading'].includes(value) ? value : 'unknown'
|
|
7
|
+
function amount(value) {
|
|
8
|
+
const row = record(value)
|
|
9
|
+
return { cost: number(row.cost), apiCost: number(row.apiCost ?? row.cost), calls: number(row.calls) }
|
|
10
|
+
}
|
|
11
|
+
function windows(value) {
|
|
12
|
+
return Object.entries(record(value)).slice(0, 8).map(([key, raw]) => {
|
|
13
|
+
const row = record(raw), percent = number(row.percent)
|
|
14
|
+
return { name: text(key), percent: percent !== null && percent <= 100 ? percent : null,
|
|
15
|
+
resetsAt: text(row.resetsAt), unlimited: row.unlimited === true }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
export function summarizeCost(state, session, sessionId) {
|
|
19
|
+
const raw = record(state), config = record(raw.config), balance = record(raw.balance)
|
|
20
|
+
const plans = Object.entries(record(raw.codingPlans)).slice(0, 20)
|
|
21
|
+
.filter(([, row]) => record(row).enabled === true && record(row).display !== 'off')
|
|
22
|
+
.map(([name, value]) => {
|
|
23
|
+
const row = record(value)
|
|
24
|
+
return { name: text(name), status: status(row.status), fetchedAt: number(row.fetchedAt), windows: windows(row.windows) }
|
|
25
|
+
})
|
|
26
|
+
const go = record(raw.goQuota)
|
|
27
|
+
if (record(config.goQuota).enabled === true && record(config.goQuota).display !== 'off') {
|
|
28
|
+
plans.unshift({ name: 'OpenCode Go', status: status(go.status), fetchedAt: number(go.fetchedAt),
|
|
29
|
+
windows: windows({ rolling: go.rolling, weekly: go.weekly, monthly: go.monthly }).filter(row => row.percent !== null) })
|
|
30
|
+
}
|
|
31
|
+
const s = record(session)
|
|
32
|
+
return { schema: 1, available: true, source: 'dsh-cost-meter', generatedAt: Date.now(), currency: 'USD',
|
|
33
|
+
day: text(record(raw.runtime).dayKey || record(raw.today).date, 10),
|
|
34
|
+
today: config.hideTodayCost === true ? null : amount(raw.today),
|
|
35
|
+
session: sessionId ? { found: s.found === true, own: s.found === true ? amount(s.own) : null,
|
|
36
|
+
subagents: s.found === true ? amount(s.subagents) : null, subagentCount: number(s.subagentCount) } : null,
|
|
37
|
+
balance: config.hideOfficialBalance === true || record(config.balance).display === 'off' ? null : {
|
|
38
|
+
status: status(balance.status), fetchedAt: number(balance.fetchedAt),
|
|
39
|
+
currency: /^[A-Z]{3}$/.test(balance.currency) ? balance.currency : '', total: number(balance.totalBalance) }, plans }
|
|
40
|
+
}
|
|
41
|
+
function bounded(task, ms = 20000) {
|
|
42
|
+
let timer
|
|
43
|
+
return Promise.race([task, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms) })])
|
|
44
|
+
.finally(() => clearTimeout(timer))
|
|
45
|
+
}
|
|
46
|
+
export async function readCostInsights(ctx, sessionId = '') {
|
|
47
|
+
if (typeof sessionId !== 'string' || sessionId.length > 200 || /[\s\x00-\x1f\x7f]/.test(sessionId)) {
|
|
48
|
+
return { status: 400, body: { ok: false, code: 'invalid-session' } }
|
|
49
|
+
}
|
|
50
|
+
let service
|
|
51
|
+
try { service = ctx.get?.('costMeter') } catch {}
|
|
52
|
+
if (!service) return { status: 200, body: { ok: true, schema: 1, available: false, code: 'not-installed' } }
|
|
53
|
+
if (typeof service.getState !== 'function') return { status: 200, body: { ok: true, schema: 1, available: false, code: 'unsupported' } }
|
|
54
|
+
try {
|
|
55
|
+
// A short shared cache coalesces device reads; no credentials or settings are sent.
|
|
56
|
+
let cache = states.get(ctx)
|
|
57
|
+
const identity = service[Symbol.for('cordis.original')] ?? service
|
|
58
|
+
if (!cache || cache.service !== identity) { cache = { service: identity, at: 0 }; states.set(ctx, cache) }
|
|
59
|
+
if (!cache.task && (!cache.value || Date.now() - cache.at > 15000)) {
|
|
60
|
+
cache.task = bounded(Promise.resolve().then(() => service.getState())).then(value => {
|
|
61
|
+
if (!value || typeof value !== 'object' || !value.today) throw new Error('unsupported')
|
|
62
|
+
cache.value = value; cache.at = Date.now(); return value
|
|
63
|
+
}).finally(() => { cache.task = null })
|
|
64
|
+
}
|
|
65
|
+
const raw = cache.task ? await cache.task : cache.value
|
|
66
|
+
const session = sessionId && typeof service.getSessionCost === 'function'
|
|
67
|
+
? await bounded(Promise.resolve().then(() => service.getSessionCost(sessionId))) : null
|
|
68
|
+
return { status: 200, body: { ok: true, ...summarizeCost(raw, session, sessionId) } }
|
|
69
|
+
} catch {
|
|
70
|
+
// Plugin failures may mention credentials, URLs or paths. Return a fixed code.
|
|
71
|
+
return { status: 502, body: { ok: false, code: 'cost-unavailable' } }
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"apk",
|
|
20
20
|
"cordis.patch.yml",
|
|
21
21
|
"*.md",
|
|
22
|
-
"plugin-center.mjs"
|
|
22
|
+
"plugin-center.mjs",
|
|
23
|
+
"insights.mjs"
|
|
23
24
|
],
|
|
24
25
|
"keywords": [
|
|
25
26
|
"dsh-plugin",
|
package/plugin-center.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Profile-scoped plugin management. No shell input, dependencies or client-selected paths. */
|
|
2
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, realpathSync, openSync, closeSync } from 'node:fs'
|
|
3
3
|
import { dirname, basename, join, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { createHash } from 'node:crypto'
|
|
@@ -20,19 +20,66 @@ const fail = (message, status = 400) => Object.assign(new Error(message), { stat
|
|
|
20
20
|
const protectedPackage = name => name === 'dsh-remote-plugin' || name.startsWith('@deepseek-ai/')
|
|
21
21
|
const revision = path => createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
function alive(pid) {
|
|
24
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
25
|
+
try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Never clear a live worker or its package-manager child. PID reuse fails closed.
|
|
29
|
+
function recoverDeadLock(storage) {
|
|
30
|
+
const path = join(storage, 'lock.json')
|
|
31
|
+
if (!existsSync(path)) return
|
|
32
|
+
const owner = read(path)
|
|
33
|
+
if (!idPattern.test(owner.id || '')) throw fail('插件任务锁损坏,请在主机检查', 409)
|
|
34
|
+
const launchPath = join(storage, 'launch-' + owner.id + '.json')
|
|
35
|
+
const workerPid = owner.workerPid || (existsSync(launchPath) ? read(launchPath).pid : null)
|
|
36
|
+
if (alive(workerPid) || alive(owner.cliPid)) return
|
|
37
|
+
if (!workerPid && (!owner.launcherPid || alive(owner.launcherPid) || Date.now() - owner.createdAt < 30000)) return
|
|
38
|
+
const jobPath = join(storage, 'job-' + owner.id + '.json')
|
|
39
|
+
if (existsSync(jobPath)) {
|
|
40
|
+
const job = read(jobPath)
|
|
41
|
+
if (['queued', 'running'].includes(job.phase)) {
|
|
42
|
+
job.phase = 'interrupted'; job.endedAt = Date.now(); job.restartRequired = true
|
|
43
|
+
job.message = '任务进程已退出,已解除锁定。文件可能已改变,请核对插件状态;未执行自动回滚。'
|
|
44
|
+
atomic(jobPath, job)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// All reads/writes here are synchronous; recheck ownership before unlinking.
|
|
48
|
+
if (read(path).id === owner.id) unlinkSync(path)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function detectProfile(ctx, cli = process.argv[1], runtime = process) {
|
|
24
52
|
try {
|
|
25
53
|
const dir = realpathSync(fileURLToPath(ctx.root?.baseUrl || ctx.baseUrl))
|
|
26
54
|
const manifest = read(join(dir, 'package.json'))
|
|
27
55
|
if (basename(dirname(dir)) !== 'profiles' || !Array.isArray(manifest.dsh?.profile?.bundles)) return null
|
|
28
56
|
const name = basename(dir)
|
|
29
57
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*(?![\s\S])/.test(name)) return null
|
|
30
|
-
|
|
58
|
+
const base = { dir, name, home: dirname(dirname(dir)), cli: null }
|
|
59
|
+
// Desktop reserves this profile for its installation-owned carrier CLI.
|
|
60
|
+
// Bind to the host that booted this root, never a global CLI or PATH entry.
|
|
61
|
+
if (name === 'desktop') {
|
|
62
|
+
try {
|
|
63
|
+
const entry = realpathSync(cli), hostRoot = dirname(dirname(entry))
|
|
64
|
+
const host = read(join(hostRoot, 'package.json'))
|
|
65
|
+
const runtimeDir = realpathSync(resolve(hostRoot, '../../..'))
|
|
66
|
+
const bundled = read(join(runtimeDir, 'package.json'))
|
|
67
|
+
const carrier = join(hostRoot, 'lib', 'cli.js')
|
|
68
|
+
if (runtime.versions?.electron && host.name === '@deepseek-ai/dsh-desktop-host'
|
|
69
|
+
&& entry === realpathSync(join(hostRoot, 'lib', 'index.js'))
|
|
70
|
+
&& bundled.name === '@deepseek-ai/dsh-desktop-runtime' && bundled.version === host.version
|
|
71
|
+
&& runtimeDir === realpathSync(runtime.argv[2]) && dir === realpathSync(runtime.argv[3])
|
|
72
|
+
&& existsSync(carrier)) return { ...base, cli: carrier, cliMode: 'desktop' }
|
|
73
|
+
} catch {}
|
|
74
|
+
return base
|
|
75
|
+
}
|
|
76
|
+
let root
|
|
77
|
+
try { root = dirname(realpathSync(cli)) } catch { return base }
|
|
31
78
|
let found = null
|
|
32
79
|
for (let i = 0; i < 5; i++, root = dirname(root)) {
|
|
33
80
|
try { if (read(join(root, 'package.json')).name === '@deepseek-ai/dsh') { found = join(root, 'lib', 'bin.js'); break } } catch {}
|
|
34
81
|
}
|
|
35
|
-
return {
|
|
82
|
+
return { ...base, cli: found && existsSync(found) ? found : null }
|
|
36
83
|
} catch { return null }
|
|
37
84
|
}
|
|
38
85
|
|
|
@@ -68,10 +115,18 @@ export function createPluginCenter(ctx, options = {}) {
|
|
|
68
115
|
const profile = options.profile === undefined ? detectProfile(ctx) : options.profile
|
|
69
116
|
const details = options.details || pluginDetails
|
|
70
117
|
const launch = options.launch || (args => {
|
|
71
|
-
const
|
|
118
|
+
const desktop = profile?.cliMode === 'desktop'
|
|
119
|
+
const errorLog = openSync(join(profile.dir, '.remote-plugin-center', 'worker-' + args[2] + '.log'), 'a', 0o600)
|
|
120
|
+
let child
|
|
121
|
+
try {
|
|
122
|
+
child = spawn(process.execPath, [...(desktop ? ['--expose-internals'] : []), SELF, '--worker', ...args], {
|
|
123
|
+
detached: true, windowsHide: true, stdio: ['ignore', 'ignore', errorLog],
|
|
124
|
+
env: { ...process.env, ...(desktop ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
|
|
125
|
+
})
|
|
126
|
+
} finally { closeSync(errorLog) }
|
|
72
127
|
return new Promise((resolveLaunch, reject) => {
|
|
73
128
|
child.once('error', reject)
|
|
74
|
-
child.once('spawn', () => { child.unref(); resolveLaunch() })
|
|
129
|
+
child.once('spawn', () => { child.unref(); resolveLaunch(child.pid) })
|
|
75
130
|
})
|
|
76
131
|
})
|
|
77
132
|
const manifestPath = profile && join(profile.dir, 'package.json')
|
|
@@ -79,6 +134,7 @@ export function createPluginCenter(ctx, options = {}) {
|
|
|
79
134
|
const storage = profile && join(profile.dir, '.remote-plugin-center')
|
|
80
135
|
let submitting = false
|
|
81
136
|
async function inventory() {
|
|
137
|
+
if (storage) recoverDeadLock(storage)
|
|
82
138
|
let runtime = [], runtimeAvailable = false
|
|
83
139
|
const loader = ctx.get?.('loader') || ctx.loader
|
|
84
140
|
if (loader?.entries) {
|
|
@@ -97,19 +153,46 @@ export function createPluginCenter(ctx, options = {}) {
|
|
|
97
153
|
if (packagePattern.test(name)) {
|
|
98
154
|
try { pkg = read(join(profile.dir, 'node_modules', ...name.split('/'), 'package.json')) } catch {}
|
|
99
155
|
}
|
|
100
|
-
|
|
156
|
+
const displayName = typeof pkg?.displayName === 'string' ? pkg.displayName : typeof pkg?.dsh?.displayName === 'string' ? pkg.dsh.displayName : name
|
|
157
|
+
return { name, displayName, version: pkg?.version || '', requested: manifest.dependencies?.[name] || '',
|
|
101
158
|
enabled: bundles.includes(name), bundle: !!pkg?.dsh?.bundle?.patch || bundles.includes(name),
|
|
102
159
|
managed: !!manifest.dependencies?.[name] && !protectedPackage(name),
|
|
103
|
-
|
|
160
|
+
description: pkg?.description || '', runtime: runtime.filter(entry => entry.name === name) }
|
|
104
161
|
})
|
|
105
162
|
let operations = []
|
|
106
163
|
if (existsSync(storage)) operations = readdirSync(storage).filter(name => /^job-[a-zA-Z0-9-]+\.json$/.test(name)).map(name => {
|
|
107
164
|
try { return read(join(storage, name)) } catch { return null }
|
|
108
165
|
}).filter(Boolean).sort((a, b) => b.startedAt - a.startedAt).slice(0, 20).map(publicJob)
|
|
166
|
+
const lockPath = join(storage, 'lock.json')
|
|
167
|
+
const owner = existsSync(lockPath) ? read(lockPath) : null
|
|
168
|
+
const legacyLock = owner && idPattern.test(owner.id || '') && !owner.launcherPid && !owner.workerPid && !owner.cliPid && !existsSync(join(storage, 'launch-' + owner.id + '.json'))
|
|
109
169
|
return { ok: true, profile: profile.name, revision: revision(manifestPath), writable: !!profile.cli,
|
|
110
|
-
reason: profile.cli ? '' :
|
|
111
|
-
|
|
112
|
-
|
|
170
|
+
reason: profile.cli ? '' : profile.name === 'desktop'
|
|
171
|
+
? '未能验证当前桌面安装的插件管理入口;仅显示状态,请检查 DSH Desktop 版本与安装完整性。'
|
|
172
|
+
: '当前启动方式没有可验证的 DSH CLI,管理操作不可用。',
|
|
173
|
+
pendingRestart: revision(manifestPath) !== initialRevision || operations.some(job => job.phase === 'interrupted' && job.restartRequired),
|
|
174
|
+
busy: submitting || !!owner, recovery: legacyLock ? { id: owner.id, reason: '旧任务锁没有进程信息,需在主机核对后手动解除。' } : null,
|
|
175
|
+
items, runtime, runtimeAvailable, operations }
|
|
176
|
+
}
|
|
177
|
+
function recoverLegacy(body) {
|
|
178
|
+
if (!profile?.cli || submitting) throw fail('当前不能恢复任务', 409)
|
|
179
|
+
const lockPath = join(storage, 'lock.json')
|
|
180
|
+
if (!existsSync(lockPath)) throw fail('任务锁已变化,请刷新', 409)
|
|
181
|
+
const owner = read(lockPath)
|
|
182
|
+
if (!idPattern.test(body.id || '') || body.id !== owner.id || owner.launcherPid || owner.workerPid || owner.cliPid || existsSync(join(storage, 'launch-' + owner.id + '.json'))) throw fail('仅支持人工恢复无进程信息的旧任务锁', 409)
|
|
183
|
+
if (revision(manifestPath) !== body.revision) throw fail('插件列表已变化,请刷新', 409)
|
|
184
|
+
if (body.confirmNoRunningProcess !== true) throw fail('请先在主机确认插件安装进程已退出', 409)
|
|
185
|
+
const jobPath = join(storage, 'job-' + owner.id + '.json')
|
|
186
|
+
if (existsSync(jobPath)) {
|
|
187
|
+
const job = read(jobPath)
|
|
188
|
+
if (['queued', 'running'].includes(job.phase)) {
|
|
189
|
+
job.phase = 'interrupted'; job.endedAt = Date.now(); job.restartRequired = true
|
|
190
|
+
job.message = '用户核对进程后解除了旧任务锁;保留现有文件和备份,未执行自动回滚。'
|
|
191
|
+
atomic(jobPath, job)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
renameSync(lockPath, join(storage, 'recovered-lock-' + owner.id + '.json'))
|
|
195
|
+
return { ok: true }
|
|
113
196
|
}
|
|
114
197
|
async function submit(body) {
|
|
115
198
|
if (!profile?.cli) throw fail('Current DSH profile is read-only', 409)
|
|
@@ -118,6 +201,7 @@ export function createPluginCenter(ctx, options = {}) {
|
|
|
118
201
|
if (!idPattern.test(id || '') || !packagePattern.test(name || '') || !['install', 'update', 'remove', 'enable', 'disable'].includes(action)) throw fail('Invalid operation')
|
|
119
202
|
if (protectedPackage(name)) throw fail('核心插件与 Remote 自身请通过主机维护流程管理', 409)
|
|
120
203
|
mkdirSync(storage, { recursive: true, mode: 0o700 })
|
|
204
|
+
recoverDeadLock(storage)
|
|
121
205
|
const jobPath = join(storage, 'job-' + id + '.json')
|
|
122
206
|
if (existsSync(jobPath)) {
|
|
123
207
|
const previous = read(jobPath)
|
|
@@ -142,12 +226,14 @@ export function createPluginCenter(ctx, options = {}) {
|
|
|
142
226
|
}
|
|
143
227
|
if (revision(manifestPath) !== body.revision) throw fail('插件配置已变化,请刷新', 409)
|
|
144
228
|
const lock = join(storage, 'lock.json')
|
|
145
|
-
try { writeFileSync(lock, JSON.stringify({ id }), { flag: 'wx', mode: 0o600 }) }
|
|
229
|
+
try { writeFileSync(lock, JSON.stringify({ id, launcherPid: process.pid, createdAt: Date.now() }), { flag: 'wx', mode: 0o600 }) }
|
|
146
230
|
catch (error) { if (error.code === 'EEXIST') throw fail('已有插件任务运行中;请查看操作记录', 409); throw error }
|
|
147
231
|
const job = { id, action, name, version: version || '', revision: body.revision, profile: profile.name, phase: 'queued', startedAt: Date.now(), log: '', restartRequired: false }
|
|
148
232
|
try {
|
|
149
233
|
atomic(jobPath, job)
|
|
150
|
-
await launch([profile.dir, profile.cli, id])
|
|
234
|
+
const pid = await launch([profile.dir, profile.cli, id, profile.cliMode || 'node'])
|
|
235
|
+
// Separate ledger: the launcher must never overwrite a worker's CLI PID.
|
|
236
|
+
if (Number.isInteger(pid) && pid > 0) atomic(join(storage, 'launch-' + id + '.json'), { pid })
|
|
151
237
|
} catch (error) {
|
|
152
238
|
job.phase = 'failed'; job.message = '无法启动插件任务'; job.endedAt = Date.now()
|
|
153
239
|
atomic(jobPath, job); unlinkSync(lock); throw error
|
|
@@ -167,6 +253,7 @@ export function createPluginCenter(ctx, options = {}) {
|
|
|
167
253
|
return { ok: true, source: REGISTRY, total: data.total || 0, items: (data.objects || []).map(({ package: p }) => ({ name: p.name, version: p.version, description: p.description || '' })) }
|
|
168
254
|
}
|
|
169
255
|
if (method === 'POST' && sub === '/operations') return { ok: true, operation: await submit(body) }
|
|
256
|
+
if (method === 'POST' && sub === '/recover') return recoverLegacy(body)
|
|
170
257
|
throw fail('Unknown plugin endpoint', 404)
|
|
171
258
|
}
|
|
172
259
|
return { handle, inventory, submit }
|
|
@@ -177,11 +264,13 @@ function publicJob(job) {
|
|
|
177
264
|
return { id, action, name, version, profile, phase, startedAt, endedAt, log, message, restartRequired }
|
|
178
265
|
}
|
|
179
266
|
|
|
180
|
-
export async function runWorker(dir, cli, id, execute = runCli) {
|
|
267
|
+
export async function runWorker(dir, cli, id, execute = runCli, cliMode = 'node') {
|
|
181
268
|
if (!idPattern.test(id || '')) throw fail('Invalid worker id')
|
|
182
269
|
const storage = join(dir, '.remote-plugin-center')
|
|
183
270
|
const lock = join(storage, 'lock.json')
|
|
184
|
-
|
|
271
|
+
const owner = read(lock)
|
|
272
|
+
if (owner.id !== id || owner.workerPid && owner.workerPid !== process.pid) throw fail('Worker does not own lock')
|
|
273
|
+
atomic(lock, { ...owner, workerPid: process.pid })
|
|
185
274
|
const jobPath = join(storage, 'job-' + id + '.json')
|
|
186
275
|
const job = read(jobPath)
|
|
187
276
|
const manifestPath = join(dir, 'package.json')
|
|
@@ -199,11 +288,11 @@ export async function runWorker(dir, cli, id, execute = runCli) {
|
|
|
199
288
|
}
|
|
200
289
|
if (existsSync(disabledPath)) copyFileSync(disabledPath, join(backup, 'disabled.json'))
|
|
201
290
|
if (['install', 'update', 'remove'].includes(job.action)) {
|
|
202
|
-
const args = job.action === 'remove' ? ['remove', job.name, '--ignore-scripts'] : ['add', job.name + '@' + job.version, '--save-exact', '--ignore-scripts', '--registry=' + REGISTRY]
|
|
291
|
+
const args = job.action === 'remove' ? ['remove', job.name, '--config.ignore-scripts=true'] : ['add', job.name + '@' + job.version, '--save-exact', '--ignore-scripts', '--registry=' + REGISTRY]
|
|
203
292
|
await execute(cli, ['plugin', '--profile', basename(dir), ...args], dir, chunk => {
|
|
204
293
|
job.log = (job.log + chunk.replace(/\x1b\[[0-9;]*m/g, '').replace(/(token|password|authorization)\s*[=:]\s*\S+/gi, '$1=[redacted]')).slice(-12000)
|
|
205
294
|
save()
|
|
206
|
-
})
|
|
295
|
+
}, pid => atomic(lock, { ...read(lock), cliPid: pid }), cliMode)
|
|
207
296
|
}
|
|
208
297
|
if (job.action === 'disable') disabled.add(job.name)
|
|
209
298
|
if (job.action === 'enable' || job.action === 'remove') disabled.delete(job.name)
|
|
@@ -230,10 +319,18 @@ export async function runWorker(dir, cli, id, execute = runCli) {
|
|
|
230
319
|
}
|
|
231
320
|
}
|
|
232
321
|
|
|
233
|
-
function
|
|
322
|
+
export function cliInvocation(cli, args, dir, cliMode = 'node', runtime = process) {
|
|
323
|
+
if (!['node', 'desktop'].includes(cliMode)) throw fail('Unsupported CLI mode')
|
|
324
|
+
return { command: runtime.execPath, args: [...(cliMode === 'desktop' ? ['--expose-internals'] : []), cli, ...args],
|
|
325
|
+
env: { ...runtime.env, DSH_HOME: dirname(dirname(dir)), npm_config_ignore_scripts: 'true',
|
|
326
|
+
...(cliMode === 'desktop' ? { ELECTRON_RUN_AS_NODE: '1' } : {}) } }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function runCli(cli, args, dir, output, onSpawn, cliMode) {
|
|
234
330
|
return new Promise((resolveRun, reject) => {
|
|
235
|
-
const
|
|
236
|
-
const child = spawn(
|
|
331
|
+
const invocation = cliInvocation(cli, args, dir, cliMode)
|
|
332
|
+
const child = spawn(invocation.command, invocation.args, { cwd: dir, env: invocation.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
333
|
+
child.once('spawn', () => onSpawn(child.pid))
|
|
237
334
|
child.stdout.on('data', chunk => output(chunk.toString()))
|
|
238
335
|
child.stderr.on('data', chunk => output(chunk.toString()))
|
|
239
336
|
child.once('error', reject)
|
|
@@ -242,5 +339,5 @@ function runCli(cli, args, dir, output) {
|
|
|
242
339
|
}
|
|
243
340
|
|
|
244
341
|
if (process.argv[1] && resolve(process.argv[1]) === SELF && process.argv[2] === '--worker') {
|
|
245
|
-
await runWorker(process.argv[3], process.argv[4], process.argv[5])
|
|
342
|
+
await runWorker(process.argv[3], process.argv[4], process.argv[5], undefined, process.argv[6])
|
|
246
343
|
}
|
package/public/admin.html
CHANGED
|
@@ -342,6 +342,7 @@
|
|
|
342
342
|
<div class="admin-title">
|
|
343
343
|
<div class="left">
|
|
344
344
|
<a id="btn-console" class="mini-btn tb-btn" href="../" data-i18n-title="consoleTitle">‹ <span data-i18n="console">控制台</span></a>
|
|
345
|
+
<a id="btn-plugin-panel" class="mini-btn tb-btn hidden" href="../plugin.html" data-i18n-title="pluginPanel">‹ <span data-i18n="pluginPanel">状态面板</span></a>
|
|
345
346
|
<button id="btn-close-drawer" class="mini-btn tb-btn hidden" data-i18n-title="collapse">‹ <span data-i18n="collapse">收起面板</span></button>
|
|
346
347
|
<h1>DSH Remote <span data-i18n="admin">管理</span></h1>
|
|
347
348
|
</div>
|
|
@@ -542,6 +543,7 @@
|
|
|
542
543
|
zh: {
|
|
543
544
|
'console': '控制台',
|
|
544
545
|
'consoleTitle': '返回控制台',
|
|
546
|
+
'pluginPanel': '状态面板',
|
|
545
547
|
'collapse': '收起面板',
|
|
546
548
|
'admin': '管理',
|
|
547
549
|
'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': '网关运行正常', 'hero.attention': '网关需要关注', 'hero.plugin': 'DSH 插件已连接', 'hero.offline': '网关未运行', 'hero.checking': '正在检查网关',
|
|
@@ -652,6 +654,7 @@
|
|
|
652
654
|
en: {
|
|
653
655
|
'console': 'Console',
|
|
654
656
|
'consoleTitle': 'Back to console',
|
|
657
|
+
'pluginPanel': 'Status panel',
|
|
655
658
|
'collapse': 'Collapse panel',
|
|
656
659
|
'admin': 'Admin',
|
|
657
660
|
'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': 'Gateway is healthy', 'hero.attention': 'Gateway needs attention', 'hero.plugin': 'DSH plugin connected', 'hero.offline': 'Gateway is not running', 'hero.checking': 'Checking gateway',
|
package/public/admin.js
CHANGED
|
@@ -1028,6 +1028,7 @@ function start(showLogin) {
|
|
|
1028
1028
|
if (pluginMode) {
|
|
1029
1029
|
$('login-view').classList.add('hidden')
|
|
1030
1030
|
$('btn-console').classList.add('hidden')
|
|
1031
|
+
if (location.protocol === 'dsh-app:' && window.parent !== window) $('btn-plugin-panel').classList.remove('hidden')
|
|
1031
1032
|
$('btn-close-drawer').classList.remove('hidden')
|
|
1032
1033
|
start(false)
|
|
1033
1034
|
} else if (token) {
|