@rezti/dsh-rez-suite 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/client.d.ts +17 -0
- package/lib/client.js +173 -6
- package/lib/index.d.ts +4 -0
- package/lib/index.js +256 -7
- package/lib/style.css +81 -0
- package/package.json +8 -7
- package/src/client/api.ts +12 -0
- package/src/client/index.ts +14 -0
- package/src/client/locales.ts +20 -0
- package/src/client/panel/ConfigTab.tsx +15 -1
- package/src/client/panel/panel.module.css +82 -0
- package/src/client/upgrade-button.tsx +115 -0
- package/src/index.ts +11 -0
- package/src/observe-mcp.ts +5 -2
- package/src/presets.ts +1 -0
- package/src/protocol.ts +15 -0
- package/src/routes.ts +30 -2
- package/src/self-update.ts +155 -0
- package/src/store.ts +6 -0
- package/src/tianyancha.ts +79 -0
- package/templates/staff/legal/.agents/skills/staff-legal/SKILL.md +1 -1
- package/templates/staff/legal/AGENTS.md +4 -0
|
@@ -294,3 +294,85 @@
|
|
|
294
294
|
font-weight: 600;
|
|
295
295
|
color: var(--dsw-alias-label-primary);
|
|
296
296
|
}
|
|
297
|
+
|
|
298
|
+
.upgradeLayer {
|
|
299
|
+
flex: none;
|
|
300
|
+
align-items: center;
|
|
301
|
+
width: 100%;
|
|
302
|
+
height: 42px;
|
|
303
|
+
margin: 8px 0 0;
|
|
304
|
+
display: flex;
|
|
305
|
+
position: relative;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.upgradeLayerRail {
|
|
309
|
+
width: 36px;
|
|
310
|
+
height: 36px;
|
|
311
|
+
margin: 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.upgradeButton {
|
|
315
|
+
width: calc(100% + 4px);
|
|
316
|
+
height: 42px;
|
|
317
|
+
color: var(--dsw-alias-label-primary);
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
background: transparent;
|
|
320
|
+
border: none;
|
|
321
|
+
border-radius: 12px;
|
|
322
|
+
align-items: center;
|
|
323
|
+
gap: 8px;
|
|
324
|
+
margin: 0 -2px;
|
|
325
|
+
padding: 0 10px 0 8px;
|
|
326
|
+
font-family: inherit;
|
|
327
|
+
font-size: 14px;
|
|
328
|
+
display: inline-flex;
|
|
329
|
+
overflow: hidden;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.upgradeButton:hover,
|
|
333
|
+
.upgradeButton[data-outdated] {
|
|
334
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.upgradeButton:disabled {
|
|
338
|
+
cursor: progress;
|
|
339
|
+
opacity: 0.72;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.upgradeButton:focus-visible {
|
|
343
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
344
|
+
outline-offset: -2px;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.upgradeLayerRail .upgradeButton {
|
|
348
|
+
border-radius: 50%;
|
|
349
|
+
justify-content: center;
|
|
350
|
+
gap: 0;
|
|
351
|
+
width: 36px;
|
|
352
|
+
height: 36px;
|
|
353
|
+
padding: 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.upgradeLabel {
|
|
357
|
+
text-overflow: ellipsis;
|
|
358
|
+
white-space: nowrap;
|
|
359
|
+
min-width: 0;
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.upgradeMeta {
|
|
364
|
+
color: var(--dsw-alias-label-tertiary);
|
|
365
|
+
font-variant-numeric: tabular-nums;
|
|
366
|
+
flex: none;
|
|
367
|
+
margin-left: auto;
|
|
368
|
+
font-size: 12px;
|
|
369
|
+
line-height: 16px;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.upgradeDot {
|
|
373
|
+
width: 7px;
|
|
374
|
+
height: 7px;
|
|
375
|
+
border-radius: 50%;
|
|
376
|
+
background: var(--dsw-alias-brand-primary);
|
|
377
|
+
flex: none;
|
|
378
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar footer action: upgrade @rezti/dsh-rez-suite without a terminal.
|
|
3
|
+
* Host code still needs a process respawn; the button does that after pnpm add.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
7
|
+
import { RezApi } from './api.ts'
|
|
8
|
+
import { tt } from './panel/helpers.ts'
|
|
9
|
+
import css from './panel/panel.module.css'
|
|
10
|
+
import type { RezUpdateStatus } from '../protocol.ts'
|
|
11
|
+
|
|
12
|
+
function UpgradeIcon() {
|
|
13
|
+
return (
|
|
14
|
+
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
|
|
15
|
+
<path
|
|
16
|
+
d="M8 12.5V4.8M8 4.8 4.8 8M8 4.8 11.2 8M3 13.2h10"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
strokeWidth="1.4"
|
|
20
|
+
strokeLinecap="round"
|
|
21
|
+
strokeLinejoin="round"
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitUntilHostBack(api: RezApi, timeoutMs = 60_000): Promise<void> {
|
|
28
|
+
const deadline = Date.now() + timeoutMs
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 800))
|
|
30
|
+
while (Date.now() < deadline) {
|
|
31
|
+
try {
|
|
32
|
+
await api.updateStatus()
|
|
33
|
+
return
|
|
34
|
+
} catch {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 700))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(tt('upgrade.reloadTimeout'))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function UpgradeButton({ wide }: { wide: boolean }) {
|
|
42
|
+
const api = useMemo(() => new RezApi(), [])
|
|
43
|
+
const [info, setInfo] = useState<RezUpdateStatus>()
|
|
44
|
+
const [busy, setBusy] = useState(false)
|
|
45
|
+
const [note, setNote] = useState('')
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let cancelled = false
|
|
49
|
+
void api.updateStatus().then((value) => {
|
|
50
|
+
if (!cancelled) setInfo(value)
|
|
51
|
+
}).catch((error: unknown) => {
|
|
52
|
+
if (!cancelled) setNote(error instanceof Error ? error.message : String(error))
|
|
53
|
+
})
|
|
54
|
+
return () => { cancelled = true }
|
|
55
|
+
}, [api])
|
|
56
|
+
|
|
57
|
+
const label = busy
|
|
58
|
+
? tt('upgrade.running')
|
|
59
|
+
: info?.outdated === true
|
|
60
|
+
? tt('upgrade.label')
|
|
61
|
+
: tt('upgrade.current')
|
|
62
|
+
|
|
63
|
+
const meta = note !== ''
|
|
64
|
+
? note
|
|
65
|
+
: info === undefined
|
|
66
|
+
? ''
|
|
67
|
+
: info.outdated
|
|
68
|
+
? info.latest
|
|
69
|
+
: info.installed
|
|
70
|
+
|
|
71
|
+
async function onClick(): Promise<void> {
|
|
72
|
+
if (busy) return
|
|
73
|
+
setBusy(true)
|
|
74
|
+
setNote('')
|
|
75
|
+
try {
|
|
76
|
+
const snapshot = info ?? await api.updateStatus()
|
|
77
|
+
setInfo(snapshot)
|
|
78
|
+
if (!snapshot.outdated) {
|
|
79
|
+
setNote(tt('upgrade.already', { version: snapshot.installed }))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const result = await api.applyUpdate()
|
|
83
|
+
setInfo(result)
|
|
84
|
+
if (result.restarting) {
|
|
85
|
+
setNote(tt('upgrade.reloading'))
|
|
86
|
+
await waitUntilHostBack(api)
|
|
87
|
+
window.location.reload()
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
setNote(tt('upgrade.already', { version: result.installed }))
|
|
91
|
+
} catch (error) {
|
|
92
|
+
setNote(error instanceof Error ? error.message : String(error))
|
|
93
|
+
} finally {
|
|
94
|
+
setBusy(false)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={wide ? css.upgradeLayer : `${css.upgradeLayer} ${css.upgradeLayerRail}`}>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className={css.upgradeButton}
|
|
103
|
+
data-outdated={info?.outdated === true ? '' : undefined}
|
|
104
|
+
aria-label={tt('upgrade.aria')}
|
|
105
|
+
disabled={busy}
|
|
106
|
+
onClick={() => { void onClick() }}
|
|
107
|
+
>
|
|
108
|
+
<UpgradeIcon />
|
|
109
|
+
{wide && <span className={css.upgradeLabel}>{label}</span>}
|
|
110
|
+
{wide && meta !== '' && <span className={css.upgradeMeta}>{meta}</span>}
|
|
111
|
+
{!wide && info?.outdated === true && <span className={css.upgradeDot} />}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { TokenManager } from './token-manager.ts'
|
|
|
28
28
|
import { rezConfigTool, rezStatusTool } from './tools.ts'
|
|
29
29
|
import type { RezConfig } from './protocol.ts'
|
|
30
30
|
import { workspaceGuidanceFor, mountRezWorkspaces } from './boss/mount.ts'
|
|
31
|
+
import { liveSelfUpdate } from './self-update.ts'
|
|
32
|
+
import { mountTianyanchaMcp } from './tianyancha.ts'
|
|
31
33
|
|
|
32
34
|
export const name = 'rez-suite'
|
|
33
35
|
|
|
@@ -78,6 +80,10 @@ export const Config: z<RezConfig> = z.object({
|
|
|
78
80
|
enabled: z.boolean().default(true),
|
|
79
81
|
siteUrl: z.string().default(REZ_DEFAULT_CONNECTIONS.gsc.siteUrl),
|
|
80
82
|
}).default(REZ_DEFAULT_CONNECTIONS.gsc),
|
|
83
|
+
tianyancha: z.object({
|
|
84
|
+
enabled: z.boolean().default(true),
|
|
85
|
+
url: z.string().default(REZ_DEFAULT_CONNECTIONS.tianyancha.url),
|
|
86
|
+
}).default(REZ_DEFAULT_CONNECTIONS.tianyancha),
|
|
81
87
|
}).default(REZ_DEFAULT_CONNECTIONS),
|
|
82
88
|
billing: z.object({
|
|
83
89
|
inputCostPer1k: z.number().default(0.001),
|
|
@@ -127,6 +133,7 @@ function delegatedHost(ctx: Context, getConfig: () => RezConfig): McpHost {
|
|
|
127
133
|
export function apply(ctx: Context, config?: RezConfig): void {
|
|
128
134
|
let current: () => RezConfig = () => normalize(config)
|
|
129
135
|
const syncWorkspaces = mountRezWorkspaces(ctx, () => current().role)
|
|
136
|
+
const syncTianyancha = mountTianyanchaMcp(ctx, () => current().role)
|
|
130
137
|
|
|
131
138
|
const tokens = new TokenManager(joinTokenDb())
|
|
132
139
|
const host = delegatedHost(ctx, () => current())
|
|
@@ -144,6 +151,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
|
|
|
144
151
|
saveConfig(value)
|
|
145
152
|
current = () => value
|
|
146
153
|
syncWorkspaces()
|
|
154
|
+
syncTianyancha()
|
|
147
155
|
try {
|
|
148
156
|
const settings = ctx.get('settings') as { update?: (namespace: unknown, value: unknown) => Promise<void> } | undefined
|
|
149
157
|
if (settings?.update !== undefined) await settings.update(REZ_SETTINGS_NAMESPACE, value)
|
|
@@ -158,6 +166,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
|
|
|
158
166
|
const emit = ctx.emit as unknown as (event: string, value: string) => void
|
|
159
167
|
emit('credentials/updated', ref)
|
|
160
168
|
},
|
|
169
|
+
selfUpdate: liveSelfUpdate(),
|
|
161
170
|
})
|
|
162
171
|
|
|
163
172
|
let disposeRoutes: (() => void) | undefined
|
|
@@ -196,10 +205,12 @@ export function apply(ctx: Context, config?: RezConfig): void {
|
|
|
196
205
|
current = source
|
|
197
206
|
sync()
|
|
198
207
|
syncWorkspaces()
|
|
208
|
+
syncTianyancha()
|
|
199
209
|
},
|
|
200
210
|
onChange: () => {
|
|
201
211
|
sync()
|
|
202
212
|
syncWorkspaces()
|
|
213
|
+
syncTianyancha()
|
|
203
214
|
},
|
|
204
215
|
})
|
|
205
216
|
|
package/src/observe-mcp.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { lastMcpStartError, REZ_CREDENTIAL_REFS, secretConfigured, type RezSystem } from '@rezti/dsh-rez-sso'
|
|
10
10
|
import type { RezConfig, RezServerStatus, RezTestResult } from './protocol.ts'
|
|
11
11
|
|
|
12
|
-
export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc'] as const
|
|
12
|
+
export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc', 'tianyancha'] as const
|
|
13
13
|
|
|
14
14
|
export type ComposedServer = (typeof COMPOSED_SERVERS)[number]
|
|
15
15
|
|
|
@@ -102,7 +102,10 @@ export function observeServer(
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
export function observeComposedServers(input: ObserveInput): RezServerStatus[] {
|
|
105
|
-
|
|
105
|
+
const servers = input.config.role === 'boss'
|
|
106
|
+
? COMPOSED_SERVERS
|
|
107
|
+
: COMPOSED_SERVERS.filter((server) => server !== 'tianyancha')
|
|
108
|
+
return servers.map(server => observeServer(server, input))
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
export function testComposedServers(input: ObserveInput): RezTestResult[] {
|
package/src/presets.ts
CHANGED
|
@@ -127,6 +127,7 @@ const COMPILED: Record<Exclude<RezRoleId, 'all' | 'boss'>, { server: RezServerId
|
|
|
127
127
|
|
|
128
128
|
/** Whether one MCP tool may be registered for the selected role. */
|
|
129
129
|
export function isToolAllowed(role: RezRoleId, server: string, rawName: string): boolean {
|
|
130
|
+
if (server === 'tianyancha') return role === 'boss'
|
|
130
131
|
if (role === 'all' || role === 'boss') return true
|
|
131
132
|
const rules = COMPILED[role]
|
|
132
133
|
return rules.some(rule => rule.server === server && rule.test.test(rawName))
|
package/src/protocol.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface RezConnections {
|
|
|
48
48
|
homeassistant: { enabled: boolean; url: string }
|
|
49
49
|
tapd: { enabled: boolean; workspaceId: string; nickName: string }
|
|
50
50
|
gsc: { enabled: boolean; siteUrl: string }
|
|
51
|
+
tianyancha: { enabled: boolean; url: string }
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface RezPublicConnection {
|
|
@@ -90,6 +91,7 @@ export interface RezPublicConfig {
|
|
|
90
91
|
homeassistant: RezPublicConnection
|
|
91
92
|
tapd: RezPublicConnection
|
|
92
93
|
gsc: RezPublicConnection
|
|
94
|
+
tianyancha: RezPublicConnection
|
|
93
95
|
}
|
|
94
96
|
billing: { inputCostPer1k: number; outputCostPer1k: number; monthlyBudget: number }
|
|
95
97
|
}
|
|
@@ -168,6 +170,18 @@ export interface ApiErrorBody {
|
|
|
168
170
|
error: string
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/** Installed vs npm latest for the suite package. */
|
|
174
|
+
export interface RezUpdateStatus {
|
|
175
|
+
package: string
|
|
176
|
+
installed: string
|
|
177
|
+
latest: string
|
|
178
|
+
outdated: boolean
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface RezUpdateApplyResult extends RezUpdateStatus {
|
|
182
|
+
restarting: boolean
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
/** Route paths the client calls (shared literals). */
|
|
172
186
|
export const REZ_API_BASE = '/api/dsh-rez-suite' as const
|
|
173
187
|
|
|
@@ -179,4 +193,5 @@ export const REZ_API = {
|
|
|
179
193
|
auditReset: REZ_API_BASE + '/audit/reset',
|
|
180
194
|
weixin: REZ_API_BASE + '/weixin',
|
|
181
195
|
weixinVerify: REZ_API_BASE + '/weixin/verify',
|
|
196
|
+
update: REZ_API_BASE + '/update',
|
|
182
197
|
} as const
|
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
writeManagedCredential,
|
|
14
14
|
type RezSystem,
|
|
15
15
|
} from '@rezti/dsh-rez-sso'
|
|
16
|
-
import type { RezConfig, RezMcpServerSpec } from './protocol.ts'
|
|
16
|
+
import type { RezConfig, RezMcpServerSpec, RezUpdateApplyResult, RezUpdateStatus } from './protocol.ts'
|
|
17
17
|
import { isRezRoleId, REZ_API } from './protocol.ts'
|
|
18
18
|
import type { McpHost } from './mcp-host.ts'
|
|
19
19
|
import type { TokenManager } from './token-manager.ts'
|
|
@@ -135,11 +135,16 @@ export interface RezRoutesDeps {
|
|
|
135
135
|
host: McpHost
|
|
136
136
|
tokens: TokenManager
|
|
137
137
|
onCredentialWritten?: (ref: string) => void
|
|
138
|
+
selfUpdate?: {
|
|
139
|
+
status: () => Promise<RezUpdateStatus>
|
|
140
|
+
apply: () => Promise<RezUpdateApplyResult>
|
|
141
|
+
relaunch: () => void
|
|
142
|
+
}
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
/** Build every /api/dsh-rez-suite route (exact paths). */
|
|
141
146
|
export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
|
|
142
|
-
const { getConfig, updateConfig, host, tokens, onCredentialWritten } = deps
|
|
147
|
+
const { getConfig, updateConfig, host, tokens, onCredentialWritten, selfUpdate } = deps
|
|
143
148
|
|
|
144
149
|
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
145
150
|
if (!isLoopbackRequest(req)) {
|
|
@@ -215,5 +220,28 @@ export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
|
|
|
215
220
|
writeJson(res, 200, { ok: true })
|
|
216
221
|
},
|
|
217
222
|
},
|
|
223
|
+
{
|
|
224
|
+
kind: 'exact',
|
|
225
|
+
path: REZ_API.update,
|
|
226
|
+
handler: async (req, res) => {
|
|
227
|
+
const method = req.method === 'POST' ? 'POST' : 'GET'
|
|
228
|
+
if (!guard(req, res, method)) return
|
|
229
|
+
if (selfUpdate === undefined) {
|
|
230
|
+
writeJson(res, 501, { error: 'self-update is not available' })
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
if (method === 'GET') {
|
|
235
|
+
writeJson(res, 200, await selfUpdate.status())
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const result = await selfUpdate.apply()
|
|
239
|
+
writeJson(res, 200, result)
|
|
240
|
+
if (result.restarting) setTimeout(() => { selfUpdate.relaunch() }, 400)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) })
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
},
|
|
218
246
|
]
|
|
219
247
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-place upgrade of @rezti/dsh-rez-suite inside the running dsh profile.
|
|
3
|
+
* pnpm add writes the new bits; Node cannot replace an already-imported host
|
|
4
|
+
* module, so we respawn the same argv after the HTTP response flushes.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import type { RezUpdateApplyResult, RezUpdateStatus } from './protocol.ts'
|
|
11
|
+
|
|
12
|
+
export const SUITE_PACKAGE = '@rezti/dsh-rez-suite'
|
|
13
|
+
const NPM_LATEST = 'https://registry.npmjs.org/@rezti/dsh-rez-suite/latest'
|
|
14
|
+
const PNPM_TIMEOUT_MS = 180_000
|
|
15
|
+
|
|
16
|
+
export function compareVersions(left: string, right: string): number {
|
|
17
|
+
const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
18
|
+
const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
19
|
+
const n = Math.max(a.length, b.length)
|
|
20
|
+
for (let i = 0; i < n; i += 1) {
|
|
21
|
+
const av = a[i] ?? 0
|
|
22
|
+
const bv = b[i] ?? 0
|
|
23
|
+
if (av > bv) return 1
|
|
24
|
+
if (av < bv) return -1
|
|
25
|
+
}
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readInstalledVersion(packageDir: string): string {
|
|
30
|
+
const raw = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { version?: unknown }
|
|
31
|
+
if (typeof raw.version !== 'string' || raw.version === '') throw new Error('suite package.json missing version')
|
|
32
|
+
return raw.version
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function findProfileDir(startDir: string): string {
|
|
36
|
+
let dir = startDir
|
|
37
|
+
for (let i = 0; i < 12; i += 1) {
|
|
38
|
+
const manifest = join(dir, 'package.json')
|
|
39
|
+
if (existsSync(manifest)) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(readFileSync(manifest, 'utf8')) as { dsh?: { profile?: { bundles?: unknown } } }
|
|
42
|
+
if (Array.isArray(raw.dsh?.profile?.bundles)) return dir
|
|
43
|
+
} catch {
|
|
44
|
+
// keep walking
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const parent = dirname(dir)
|
|
48
|
+
if (parent === dir) break
|
|
49
|
+
dir = parent
|
|
50
|
+
}
|
|
51
|
+
throw new Error('cannot find dsh profile directory (no package.json with dsh.profile.bundles)')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function shellQuote(value: string): string {
|
|
55
|
+
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function relaunchScript(argv: string[], parentPid: number): string {
|
|
59
|
+
return `while kill -0 ${parentPid} 2>/dev/null; do sleep 0.15; done; sleep 0.25; exec ${argv.map(shellQuote).join(' ')}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function fetchLatestVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
|
|
63
|
+
const response = await fetchImpl(NPM_LATEST, { headers: { accept: 'application/json' } })
|
|
64
|
+
if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`)
|
|
65
|
+
const body = await response.json() as { version?: unknown }
|
|
66
|
+
if (typeof body.version !== 'string' || body.version === '') throw new Error('npm registry missing version')
|
|
67
|
+
return body.version
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function runPnpmAdd(cwd: string, spec: string, env: NodeJS.ProcessEnv = process.env): Promise<void> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const child = spawn('pnpm', ['add', spec], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
73
|
+
let stderr = ''
|
|
74
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
child.kill('SIGTERM')
|
|
77
|
+
reject(new Error('pnpm add timed out after 3 minutes'))
|
|
78
|
+
}, PNPM_TIMEOUT_MS)
|
|
79
|
+
child.on('error', (error) => {
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
reject(error)
|
|
82
|
+
})
|
|
83
|
+
child.on('close', (code) => {
|
|
84
|
+
clearTimeout(timer)
|
|
85
|
+
if (code === 0) resolve()
|
|
86
|
+
else reject(new Error(stderr.trim() || `pnpm add exited ${String(code)}`))
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function spawnRelaunch(argv: string[], parentPid: number, cwd: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
92
|
+
const child = spawn('/bin/bash', ['-c', relaunchScript(argv, parentPid)], {
|
|
93
|
+
cwd,
|
|
94
|
+
env,
|
|
95
|
+
detached: true,
|
|
96
|
+
stdio: 'ignore',
|
|
97
|
+
})
|
|
98
|
+
child.unref()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SelfUpdateIo {
|
|
102
|
+
packageDir: string
|
|
103
|
+
fetchLatest: () => Promise<string>
|
|
104
|
+
install: (profileDir: string, spec: string) => Promise<void>
|
|
105
|
+
relaunch: () => void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let inflight: Promise<unknown> | undefined
|
|
109
|
+
|
|
110
|
+
export function createSelfUpdate(io: SelfUpdateIo) {
|
|
111
|
+
const status = async (): Promise<RezUpdateStatus> => {
|
|
112
|
+
const installed = readInstalledVersion(io.packageDir)
|
|
113
|
+
const latest = await io.fetchLatest()
|
|
114
|
+
return {
|
|
115
|
+
package: SUITE_PACKAGE,
|
|
116
|
+
installed,
|
|
117
|
+
latest,
|
|
118
|
+
outdated: compareVersions(latest, installed) > 0,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const apply = async (): Promise<RezUpdateApplyResult> => {
|
|
123
|
+
if (inflight !== undefined) throw new Error('upgrade already running')
|
|
124
|
+
const work = (async () => {
|
|
125
|
+
const snapshot = await status()
|
|
126
|
+
if (!snapshot.outdated) {
|
|
127
|
+
return { ...snapshot, restarting: false }
|
|
128
|
+
}
|
|
129
|
+
const profileDir = findProfileDir(io.packageDir)
|
|
130
|
+
await io.install(profileDir, `${SUITE_PACKAGE}@${snapshot.latest}`)
|
|
131
|
+
return { ...snapshot, installed: snapshot.latest, outdated: false, restarting: true }
|
|
132
|
+
})()
|
|
133
|
+
inflight = work
|
|
134
|
+
try {
|
|
135
|
+
return await work
|
|
136
|
+
} finally {
|
|
137
|
+
inflight = undefined
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { status, apply, relaunch: io.relaunch }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function liveSelfUpdate(): ReturnType<typeof createSelfUpdate> {
|
|
145
|
+
const packageDir = dirname(fileURLToPath(new URL('.', import.meta.url)))
|
|
146
|
+
return createSelfUpdate({
|
|
147
|
+
packageDir,
|
|
148
|
+
fetchLatest: () => fetchLatestVersion(),
|
|
149
|
+
install: (profileDir, spec) => runPnpmAdd(profileDir, spec),
|
|
150
|
+
relaunch: () => {
|
|
151
|
+
spawnRelaunch(process.argv, process.pid, process.cwd())
|
|
152
|
+
setTimeout(() => { process.exit(0) }, 50)
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -125,6 +125,12 @@ export function publicConfig(config: RezConfig): RezPublicConfig {
|
|
|
125
125
|
secretConfigured: secretConfigured('gsc'),
|
|
126
126
|
secretRef: REZ_CREDENTIAL_REFS.gsc,
|
|
127
127
|
},
|
|
128
|
+
tianyancha: {
|
|
129
|
+
enabled: connections.tianyancha.enabled,
|
|
130
|
+
url: connections.tianyancha.url,
|
|
131
|
+
secretConfigured: secretConfigured('tianyancha'),
|
|
132
|
+
secretRef: REZ_CREDENTIAL_REFS.tianyancha,
|
|
133
|
+
},
|
|
128
134
|
},
|
|
129
135
|
billing: { ...config.billing },
|
|
130
136
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TianyanCha official MCP for the 法务助理 workspace.
|
|
3
|
+
* Reuse https://mcp.tianyancha.com/mcp via dsh-mcp-client. Do not spawn a
|
|
4
|
+
* local 162-tool catalog — tools/list only exposes a few agent entrypoints.
|
|
5
|
+
* Start the client only when suite role is boss so other identities never
|
|
6
|
+
* see mcp__tianyancha__* in the prompt.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { loadRezConnections, startMcpWhenSecret } from '@rezti/dsh-rez-sso'
|
|
10
|
+
import type { RezRoleId } from './protocol.ts'
|
|
11
|
+
|
|
12
|
+
export const TIANYANCHA_MCP_URL = 'https://mcp.tianyancha.com/mcp'
|
|
13
|
+
|
|
14
|
+
export function shouldStartTianyancha(role: RezRoleId): boolean {
|
|
15
|
+
return role === 'boss'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function bearerAuthorization(secret: string): string {
|
|
19
|
+
const trimmed = secret.trim()
|
|
20
|
+
if (/^bearer\s+/i.test(trimmed)) return 'Bearer ' + trimmed.replace(/^bearer\s+/i, '')
|
|
21
|
+
return 'Bearer ' + trimmed
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function tianyanchaMcpConfig(secret: string): Record<string, unknown> {
|
|
25
|
+
const { url } = loadRezConnections().tianyancha
|
|
26
|
+
return {
|
|
27
|
+
serverName: 'tianyancha',
|
|
28
|
+
transport: 'streamable-http',
|
|
29
|
+
url: url.length > 0 ? url : TIANYANCHA_MCP_URL,
|
|
30
|
+
headers: { Authorization: bearerAuthorization(secret) },
|
|
31
|
+
failOnStartupError: false,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Start when role is boss; dispose the mcp-client fiber when the identity leaves. */
|
|
36
|
+
export function mountTianyanchaMcp(
|
|
37
|
+
ctx: Context,
|
|
38
|
+
getRole: () => RezRoleId,
|
|
39
|
+
start: typeof startMcpWhenSecret = startMcpWhenSecret,
|
|
40
|
+
): () => void {
|
|
41
|
+
let session: (() => void) | undefined
|
|
42
|
+
let pending = false
|
|
43
|
+
|
|
44
|
+
const ensure = (): void => {
|
|
45
|
+
void (async () => {
|
|
46
|
+
const want = shouldStartTianyancha(getRole())
|
|
47
|
+
if (!want) {
|
|
48
|
+
session?.()
|
|
49
|
+
session = undefined
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (session !== undefined || pending) return
|
|
53
|
+
pending = true
|
|
54
|
+
let raced = false
|
|
55
|
+
try {
|
|
56
|
+
const dispose = await start(ctx, {
|
|
57
|
+
system: 'tianyancha',
|
|
58
|
+
label: '天眼查',
|
|
59
|
+
config: tianyanchaMcpConfig,
|
|
60
|
+
})
|
|
61
|
+
if (!shouldStartTianyancha(getRole())) {
|
|
62
|
+
raced = true
|
|
63
|
+
dispose()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
session = dispose
|
|
67
|
+
} catch (error: unknown) {
|
|
68
|
+
const why = error instanceof Error ? error.message : String(error)
|
|
69
|
+
console.error('[dsh-rez-suite] tianyancha MCP start failed:', why)
|
|
70
|
+
} finally {
|
|
71
|
+
pending = false
|
|
72
|
+
if (raced && shouldStartTianyancha(getRole()) && session === undefined) ensure()
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ensure()
|
|
78
|
+
return ensure
|
|
79
|
+
}
|