scanruler-mcp 0.2.0 → 0.4.0

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/update.js ADDED
@@ -0,0 +1,380 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // The server keeps itself current. A config written as `npx -y
3
+ // scanruler-mcp` is rarely changed again, and a copy installed or copied by
4
+ // hand never updates at all; so the server does it by itself, in two halves
5
+ // that never hold up the start:
6
+ //
7
+ // - In the background, once it serves, it asks npm's registry which version
8
+ // is latest — once a day, 2 s at most, the answer kept in update.json
9
+ // beside tools.json — and when that is newer than itself, has npm fetch it
10
+ // into npm's cache: `npm exec --yes --package=scanruler-mcp@<v> --
11
+ // scanruler-mcp --version`, which runs the fetched copy once, so a copy
12
+ // that does not start is never taken. The session in progress goes on.
13
+ // - At the next start the launcher (bin/scanruler-mcp.js) finds that copy in
14
+ // update.json (`ready: { version, bin }` — a contract every later version
15
+ // keeps, since the copy that reads it may be an old one) and runs it in its
16
+ // own place: the same arguments, stdio passed straight through, its exit
17
+ // code passed back. No network before serving.
18
+ //
19
+ // Every failure leaves the copy that is running: offline, registry down,
20
+ // fetch or start failed. Off for a copy run from a git checkout (the dev
21
+ // server), with --no-update or SCANRULER_MCP_NO_UPDATE=1, and for a spec
22
+ // pinned in the config (`npx -y scanruler-mcp@0.4.0` is taken as meant).
23
+ // The hand-off is marked in the environment, so a handed-to copy never hands
24
+ // off again in the same start.
25
+ //
26
+ // npx keeps each spec in its own folder, <npm cache>/_npx/<hash>, the hash
27
+ // the first 16 hex digits of the SHA-512 of the spec as written (npm's
28
+ // libnpmexec). A copy run by npx tells from that hash whether its spec was
29
+ // pinned, and finds npm's cache from its own path.
30
+
31
+ import { spawn } from 'node:child_process'
32
+ import { createHash } from 'node:crypto'
33
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
34
+ import { homedir, tmpdir } from 'node:os'
35
+ import { basename, dirname, join } from 'node:path'
36
+ import { fileURLToPath } from 'node:url'
37
+ import { configDir } from './config.js'
38
+
39
+ export const PACKAGE = 'scanruler-mcp'
40
+ /** Set on the copy handed to: the version that handed off. */
41
+ export const HANDED_OFF_ENV = 'SCANRULER_MCP_HANDED_OFF'
42
+ /** The launcher's process id, for the copy handed to to stop with it. */
43
+ export const PARENT_ENV = 'SCANRULER_MCP_PARENT_PID'
44
+ export const NO_UPDATE_ENV = 'SCANRULER_MCP_NO_UPDATE'
45
+ export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000
46
+ export const CHECK_WITHIN_MS = 2000
47
+ /** A handed-to copy that fails sooner than this is taken as not starting. */
48
+ export const START_GRACE_MS = 3000
49
+ const FETCH_WITHIN_MS = 5 * 60 * 1000
50
+ const STATE_FILE = 'update.json'
51
+ /** Options that answer and exit: never handed off. */
52
+ const OWN_ANSWERS = ['--help', '--pair', '--version']
53
+
54
+ const PACKAGE_DIR = fileURLToPath(new URL('..', import.meta.url))
55
+
56
+ // ---- versions -------------------------------------------------------------
57
+
58
+ const parse = (v) => {
59
+ const m = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(typeof v === 'string' ? v : '')
60
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? null] : null
61
+ }
62
+
63
+ export const isVersion = (v) => parse(v) !== null
64
+
65
+ /** Whether version `a` is newer than `b`; false when either is no version. */
66
+ export function newer(a, b) {
67
+ const x = parse(a)
68
+ const y = parse(b)
69
+ if (!x || !y) return false
70
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]
71
+ return x[3] === null && y[3] !== null
72
+ }
73
+
74
+ /** npx's folder name for a spec: <npm cache>/_npx/<this>. */
75
+ export const npxHash = (spec) => createHash('sha512').update(spec).digest('hex').slice(0, 16)
76
+
77
+ /** The version of the copy whose bin is `bin`, or null. */
78
+ export function versionAt(bin, read = readFileSync) {
79
+ try {
80
+ const v = JSON.parse(read(join(dirname(bin), '..', 'package.json'), 'utf8')).version
81
+ return isVersion(v) ? v : null
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ // ---- how this copy runs -------------------------------------------------------
88
+
89
+ /** How the copy in `packageDir` was installed: by npx (`npx`, with npm's
90
+ * cache, and pinned unless its spec was the bare name or @latest), by npm
91
+ * (`installed`: global or a project's), from a git checkout (`checkout`),
92
+ * or copied by hand (`copy`). */
93
+ export function installOf(packageDir = PACKAGE_DIR, exists = existsSync) {
94
+ const parent = dirname(packageDir)
95
+ if (basename(parent) === 'node_modules') {
96
+ const npxDir = dirname(parent)
97
+ if (basename(dirname(npxDir)) === '_npx') {
98
+ const hash = basename(npxDir)
99
+ const bare = [PACKAGE, `${PACKAGE}@latest`].some((spec) => npxHash(spec) === hash)
100
+ return { kind: 'npx', pinned: !bare, npmCache: dirname(dirname(npxDir)) }
101
+ }
102
+ return { kind: 'installed', pinned: false }
103
+ }
104
+ for (let dir = parent; ; dir = dirname(dir)) {
105
+ if (exists(join(dir, '.git'))) return { kind: 'checkout', pinned: false }
106
+ if (dirname(dir) === dir) return { kind: 'copy', pinned: false }
107
+ }
108
+ }
109
+
110
+ /** Whether this copy keeps itself current: `updates` is `on`, `off`
111
+ * (--no-update, SCANRULER_MCP_NO_UPDATE), `checkout` or `pinned`. A copy
112
+ * handed to goes by the launcher's decision, not its own folder — which is
113
+ * a pinned spec's. */
114
+ export function updatePolicy({ argv = [], env = process.env, install }) {
115
+ const handedOffFrom = env[HANDED_OFF_ENV] || null
116
+ const from = handedOffFrom ? { handedOffFrom } : {}
117
+ if (argv.includes('--no-update') || /^(1|true|yes|on)$/i.test(env[NO_UPDATE_ENV] ?? '')) return { updates: 'off', ...from }
118
+ if (handedOffFrom) return { updates: 'on', ...from }
119
+ if (install.kind === 'checkout') return { updates: 'checkout' }
120
+ if (install.pinned) return { updates: 'pinned', pinned: true }
121
+ return { updates: 'on' }
122
+ }
123
+
124
+ // ---- what is kept ---------------------------------------------------------------
125
+
126
+ /** update.json: `{ checked, latest, ready?: { version, bin }, failed? }`. */
127
+ export function loadState(dir = configDir()) {
128
+ try {
129
+ const kept = JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8'))
130
+ return kept && typeof kept === 'object' ? kept : {}
131
+ } catch {
132
+ return {}
133
+ }
134
+ }
135
+
136
+ /** Merge `patch` into update.json — written whole and renamed into place,
137
+ * since two copies may start at once. */
138
+ export function saveState(patch, dir = configDir()) {
139
+ const next = { ...loadState(dir), ...patch }
140
+ try {
141
+ mkdirSync(dir, { recursive: true })
142
+ const file = join(dir, STATE_FILE)
143
+ const part = `${file}.${process.pid}.part`
144
+ writeFileSync(part, JSON.stringify(next, null, 2) + '\n')
145
+ renameSync(part, file)
146
+ } catch {
147
+ // Not kept: the next start asks again.
148
+ }
149
+ return next
150
+ }
151
+
152
+ // ---- the launcher ---------------------------------------------------------------
153
+
154
+ /** What the launcher runs: `{ run: 'self', why }` or `{ run: 'handoff',
155
+ * bin, version }` — read from update.json and the disk alone. */
156
+ export function launchPlan({ argv = [], env = process.env, version, install, state = {}, exists = existsSync, readVersion = versionAt }) {
157
+ const self = (why) => ({ run: 'self', why })
158
+ if (env[HANDED_OFF_ENV]) return self('handed-off')
159
+ if (argv.some((a) => OWN_ANSWERS.includes(a))) return self('answer')
160
+ const policy = updatePolicy({ argv, env, install })
161
+ if (policy.updates !== 'on') return self(policy.updates)
162
+ const ready = state.ready
163
+ if (!ready || typeof ready.bin !== 'string' || !newer(ready.version, version)) return self('current')
164
+ if (Array.isArray(state.failed) && state.failed.includes(ready.version)) return self('failed-before')
165
+ if (!exists(ready.bin) || readVersion(ready.bin) !== ready.version) return self('missing')
166
+ return { run: 'handoff', bin: ready.bin, version: ready.version }
167
+ }
168
+
169
+ /** Run the copy at `plan.bin` in this one's place. Resolves `{ code }` with
170
+ * its exit code, or `{ fallback }` when it did not start — failed to
171
+ * spawn, or ended with an error within START_GRACE_MS, then marked failed
172
+ * so the next start does not try it again. */
173
+ export function runHandOff({ plan, argv, version, env = process.env, spawnFn = spawn, now = Date.now, markFailed = () => {}, execPath = process.execPath }) {
174
+ return new Promise((resolve) => {
175
+ let child
176
+ try {
177
+ child = spawnFn(execPath, [plan.bin, ...argv], {
178
+ stdio: 'inherit',
179
+ env: { ...env, [HANDED_OFF_ENV]: version, [PARENT_ENV]: String(process.pid) },
180
+ windowsHide: true,
181
+ })
182
+ } catch (e) {
183
+ resolve({ fallback: `could not start it: ${e?.message ?? e}` })
184
+ return
185
+ }
186
+ const started = now()
187
+ const forward = (signal) => () => child.kill(signal)
188
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((s) => [s, forward(s)])
189
+ for (const [s, f] of signals) process.on(s, f)
190
+ const done = (out) => {
191
+ for (const [s, f] of signals) process.off(s, f)
192
+ resolve(out)
193
+ }
194
+ child.once('error', (e) => done({ fallback: `could not start it: ${e?.message ?? e}` }))
195
+ child.once('exit', (code, signal) => {
196
+ if (code !== 0 && signal === null && now() - started < START_GRACE_MS) {
197
+ markFailed(plan.version)
198
+ done({ fallback: `it stopped with code ${code} as it started` })
199
+ } else done({ code: code ?? 1 })
200
+ })
201
+ })
202
+ }
203
+
204
+ /** The launcher's part: run a newer copy fetched earlier in this one's
205
+ * place. Resolves its exit code, or null for this copy to run — always
206
+ * null on any failure. */
207
+ export async function launch(argv, { env = process.env, packageDir = PACKAGE_DIR, dir, log = stderrLog } = {}) {
208
+ try {
209
+ const version = versionAt(join(packageDir, 'bin', 'scanruler-mcp.js'))
210
+ const where = dir ?? configDir(env)
211
+ const plan = launchPlan({ argv, env, version, install: installOf(packageDir), state: loadState(where) })
212
+ if (plan.run !== 'handoff') return null
213
+ log(`Starting scanruler-mcp ${plan.version}, fetched earlier, in place of ${version}.`)
214
+ const out = await runHandOff({
215
+ plan,
216
+ argv,
217
+ version,
218
+ env,
219
+ markFailed: (v) => {
220
+ const failed = loadState(where).failed
221
+ saveState({ failed: [...(Array.isArray(failed) ? failed : []), v].slice(-5) }, where)
222
+ },
223
+ })
224
+ if ('code' in out) return out.code
225
+ log(`scanruler-mcp ${plan.version} did not start (${out.fallback}); running ${version}.`)
226
+ return null
227
+ } catch {
228
+ return null
229
+ }
230
+ }
231
+
232
+ /** In a copy handed to: stop when the launcher is gone — killed by the
233
+ * client, which on Windows does not take its children with it. */
234
+ export function watchParent(env = process.env, { every = 2000, alive = processAlive, exit = () => process.exit(0) } = {}) {
235
+ const pid = Number(env[PARENT_ENV])
236
+ if (!env[HANDED_OFF_ENV] || !Number.isInteger(pid) || pid <= 0) return null
237
+ const timer = setInterval(() => {
238
+ if (!alive(pid)) exit()
239
+ }, every)
240
+ timer.unref?.()
241
+ return timer
242
+ }
243
+
244
+ function processAlive(pid) {
245
+ try {
246
+ process.kill(pid, 0)
247
+ return true
248
+ } catch (e) {
249
+ return e?.code === 'EPERM'
250
+ }
251
+ }
252
+
253
+ const stderrLog = (line) => process.stderr.write(`[scanruler-mcp] ${line}\n`)
254
+
255
+ // ---- the background check ---------------------------------------------------------
256
+
257
+ /** The latest version on the registry npm uses, or null — 2 s at most. */
258
+ export async function fetchLatest({ env = process.env, fetchFn = fetch, within = CHECK_WITHIN_MS } = {}) {
259
+ try {
260
+ const registry = (env.npm_config_registry || 'https://registry.npmjs.org/').replace(/\/?$/, '/')
261
+ const r = await fetchFn(`${registry}${PACKAGE}/latest`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(within) })
262
+ if (!r.ok) return null
263
+ const { version } = await r.json()
264
+ return isVersion(version) ? version : null
265
+ } catch {
266
+ return null
267
+ }
268
+ }
269
+
270
+ /** How to run npm without a shell: the npm that ran npx, or the one beside
271
+ * this Node; `npm` through the shell as the last resort. */
272
+ export function npmCommand(env = process.env, execPath = process.execPath, exists = existsSync) {
273
+ const given = env.npm_execpath ?? ''
274
+ const candidates = [
275
+ /npm-cli\.js$/.test(given) ? given : null,
276
+ /npx-cli\.js$/.test(given) ? join(dirname(given), 'npm-cli.js') : null,
277
+ join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
278
+ join(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
279
+ ]
280
+ const cli = candidates.find((c) => c && exists(c))
281
+ return cli ? { file: execPath, args: [cli], shell: false } : { file: 'npm', args: [], shell: process.platform === 'win32' }
282
+ }
283
+
284
+ /** Run npm with `args` (fixed words and a checked version only — they may
285
+ * pass a shell), quietly, from the temp folder: `{ code, stdout }`. */
286
+ export function runNpm(args, { env = process.env, within = FETCH_WITHIN_MS, spawnFn = spawn } = {}) {
287
+ return new Promise((resolve) => {
288
+ const npm = npmCommand(env)
289
+ let child
290
+ try {
291
+ child = spawnFn(npm.file, [...npm.args, ...args], {
292
+ cwd: tmpdir(),
293
+ env: { ...env, [NO_UPDATE_ENV]: '1', [HANDED_OFF_ENV]: '' },
294
+ stdio: ['ignore', 'pipe', 'ignore'],
295
+ shell: npm.shell,
296
+ windowsHide: true,
297
+ })
298
+ } catch {
299
+ resolve({ code: -1, stdout: '' })
300
+ return
301
+ }
302
+ let stdout = ''
303
+ child.stdout?.on('data', (d) => {
304
+ if (stdout.length < 4096) stdout += d
305
+ })
306
+ const timer = setTimeout(() => child.kill(), within)
307
+ timer.unref?.()
308
+ child.once('error', () => {
309
+ clearTimeout(timer)
310
+ resolve({ code: -1, stdout })
311
+ })
312
+ child.once('close', (code) => {
313
+ clearTimeout(timer)
314
+ resolve({ code: code ?? -1, stdout })
315
+ })
316
+ })
317
+ }
318
+
319
+ /** npm's cache folder when nobody says otherwise. */
320
+ const defaultNpmCache = (env, platform = process.platform) =>
321
+ platform === 'win32' ? join(env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'npm-cache') : join(homedir(), '.npm')
322
+
323
+ /** Have npm fetch `version` into its cache and run it once; the bin of the
324
+ * copy fetched, or null. */
325
+ export async function prefetch(version, { env = process.env, install = { kind: 'copy' }, npm = runNpm, readVersion = versionAt } = {}) {
326
+ if (!isVersion(version)) return null
327
+ const spec = `${PACKAGE}@${version}`
328
+ const ran = await npm(['exec', '--yes', `--package=${spec}`, '--', PACKAGE, '--version'], { env })
329
+ if (ran.code !== 0 || ran.stdout.trim() !== version) return null
330
+ const binIn = (cache) => join(cache, '_npx', npxHash(spec), 'node_modules', PACKAGE, 'bin', 'scanruler-mcp.js')
331
+ const caches = [install.npmCache, env.npm_config_cache, defaultNpmCache(env)].filter(Boolean)
332
+ for (const cache of caches) if (readVersion(binIn(cache)) === version) return binIn(cache)
333
+ const asked = await npm(['config', 'get', 'cache'], { env, within: 30_000 })
334
+ const cache = asked.code === 0 ? asked.stdout.trim() : ''
335
+ return cache && readVersion(binIn(cache)) === version ? binIn(cache) : null
336
+ }
337
+
338
+ /** The background check: ask the registry (once a day), and fetch a newer
339
+ * version for the next start. Resolves the state kept. */
340
+ export async function checkForUpdate({ version, dir = configDir(), env = process.env, install = { kind: 'copy' }, now = Date.now, latestOf = () => fetchLatest({ env }), fetchCopy = (v) => prefetch(v, { env, install }), exists = existsSync }) {
341
+ let state = loadState(dir)
342
+ const checked = Date.parse(state.checked ?? '')
343
+ if (!Number.isFinite(checked) || now() - checked > CHECK_EVERY_MS || now() < checked) {
344
+ const latest = await latestOf()
345
+ if (!latest) return state
346
+ state = saveState({ checked: new Date(now()).toISOString(), latest }, dir)
347
+ }
348
+ const latest = state.latest
349
+ if (!newer(latest, version)) return state
350
+ if (Array.isArray(state.failed) && state.failed.includes(latest)) return state
351
+ if (state.ready?.version === latest && exists(state.ready.bin)) return state
352
+ const bin = await fetchCopy(latest)
353
+ return bin ? saveState({ ready: { version: latest, bin, fetched: new Date(now()).toISOString() } }, dir) : state
354
+ }
355
+
356
+ /** The server's part, once it serves: the check in the background, and
357
+ * what the status says of it — `{ latest, handedOffFrom?, pinned?,
358
+ * updates? }` (updates only when off: `off` or `checkout`). */
359
+ export function startUpdates({ version, argv = [], env = process.env, packageDir = PACKAGE_DIR, dir = configDir(env), log = stderrLog, check = checkForUpdate } = {}) {
360
+ const install = installOf(packageDir)
361
+ const policy = updatePolicy({ argv, env, install })
362
+ let state = policy.updates === 'on' ? loadState(dir) : {}
363
+ const done =
364
+ policy.updates === 'on'
365
+ ? new Promise((resolve) => setImmediate(resolve))
366
+ .then(() => check({ version, dir, env, install }))
367
+ .then((s) => {
368
+ state = s
369
+ if (newer(s.ready?.version, version)) log(`scanruler-mcp ${s.ready.version} is fetched; it starts in place of ${version} next time.`)
370
+ })
371
+ .catch(() => {})
372
+ : Promise.resolve()
373
+ const info = () => ({
374
+ latest: isVersion(state.latest) ? state.latest : null,
375
+ ...(policy.handedOffFrom ? { handedOffFrom: policy.handedOffFrom } : {}),
376
+ ...(policy.pinned ? { pinned: true } : {}),
377
+ ...(policy.updates === 'off' || policy.updates === 'checkout' ? { updates: policy.updates } : {}),
378
+ })
379
+ return { info, done }
380
+ }