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/README.md +431 -241
- package/bin/scanruler-mcp.js +15 -8
- package/package.json +1 -1
- package/src/commands.json +4435 -2504
- package/src/config.js +38 -6
- package/src/engine.js +69 -0
- package/src/link.js +304 -285
- package/src/main.js +105 -5
- package/src/mcp.js +1155 -290
- package/src/relay.js +218 -217
- package/src/tools.js +207 -14
- package/src/trace.js +55 -0
- package/src/update.js +380 -0
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
|
+
}
|