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.
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
- const ok = authorized(req, url)
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.1",
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
- export function detectProfile(ctx, cli = process.argv[1]) {
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
- let root = dirname(realpathSync(cli))
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 { dir, name, home: dirname(dirname(dir)), cli: found && existsSync(found) ? found : null }
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 child = spawn(process.execPath, [SELF, '--worker', ...args], { detached: true, windowsHide: true, stdio: 'ignore' })
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
- return { name, version: pkg?.version || '', requested: manifest.dependencies?.[name] || '',
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
- description: pkg?.description || '' }
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 ? '' : '当前启动方式没有可验证的 DSH CLI,管理操作不可用。',
111
- pendingRestart: revision(manifestPath) !== initialRevision,
112
- busy: submitting || existsSync(join(storage, 'lock.json')), items, runtime, runtimeAvailable, operations }
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
- if (read(lock).id !== id) throw fail('Worker does not own lock')
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 runCli(cli, args, dir, output) {
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 env = { ...process.env, DSH_HOME: dirname(dirname(dir)), npm_config_ignore_scripts: 'true' }
236
- const child = spawn(process.execPath, [cli, ...args], { cwd: dir, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
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) {