@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/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
+ }