@rezti/dsh-rez-suite 0.1.18 → 0.1.20
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 +2 -2
- package/lib/client.d.ts +14 -0
- package/lib/client.js +142 -5
- package/lib/index.d.ts +3 -2
- package/lib/index.js +246 -20
- package/lib/style.css +81 -0
- package/package.json +8 -7
- package/src/boss/mount.ts +46 -13
- package/src/boss/register.ts +38 -2
- package/src/client/api.ts +12 -0
- package/src/client/index.ts +14 -0
- package/src/client/locales.ts +14 -0
- package/src/client/panel/panel.module.css +82 -0
- package/src/client/upgrade-button.tsx +115 -0
- package/src/index.ts +16 -6
- package/src/protocol.ts +13 -0
- package/src/routes.ts +30 -2
- package/src/self-update.ts +155 -0
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
|
+
}
|