scanruler-mcp 0.3.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/tools.js CHANGED
@@ -6,10 +6,11 @@
6
6
  // it to the page; a command that hands a file back takes an optional path,
7
7
  // and this server writes the file there. The agent never sees the bytes —
8
8
  // but for a picture (a command marked returnsImage), which it is shown as an
9
- // image, and which is written to a file only when it gives a path.
9
+ // image, and which is written to a file only when it gives a path — or, in
10
+ // a batch, where it cannot be shown, always.
10
11
 
11
- import { readFile, stat, writeFile, mkdir } from 'node:fs/promises'
12
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
12
+ import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
13
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
13
14
 
14
15
  export const toolName = (command) => command.name.replace(/\./g, '_')
15
16
 
@@ -31,10 +32,27 @@ const outPath = (outDir) => ({
31
32
  description: `Where to write the file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it goes into ${outDir}.`,
32
33
  })
33
34
 
34
- const imagePath = {
35
+ const imagePath = (outDir) => ({
35
36
  type: 'string',
36
37
  minLength: 1,
37
- description: 'Also write the picture to a file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it is only shown.',
38
+ description: `Also write the picture to a file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written into ${outDir} under a new name.`,
39
+ })
40
+
41
+ /** The `saveTo` a command that replaces the session takes — one with
42
+ * `discard`: the session saved as a project file first (mcp.js). */
43
+ const saveToIn = {
44
+ type: 'string',
45
+ minLength: 1,
46
+ description: 'Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo).',
47
+ }
48
+
49
+ /** Whether a command replaces the session, refusing to over work unless
50
+ * told `discard` — what `saveTo` is offered on. */
51
+ export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
52
+
53
+ const inlineIn = {
54
+ type: 'boolean',
55
+ description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
38
56
  }
39
57
 
40
58
  /** How many significant digits a number keeps in an answer, and the finest
@@ -60,11 +78,12 @@ export const agentJson = (value, exact = false) =>
60
78
  exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
61
79
 
62
80
  /** A command as the MCP tool an agent sees. */
63
- /** The `wait` a tool that changes the session takes: the server runs every
64
- * such call as a job (mcp.js), and false answers with the job at once. */
81
+ /** The `wait` a tool that changes the session takes, and a long read
82
+ * (Command.long): the server runs every such call as a job (mcp.js), and
83
+ * false answers with the job at once. */
65
84
  export const waitIn = {
66
85
  type: 'boolean',
67
- description: 'false: answer at once with { queued: true, job } while the change runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done.',
86
+ description: 'false: answer at once with { queued: true, job } while the call runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done — or, still running at the server’s answer limit (scanruler_status: server.answerWithin, seconds), with { queued: true, job, after } all the same.',
68
87
  }
69
88
 
70
89
  /** The `timings` a tool that changes the session takes: the server keeps
@@ -82,7 +101,8 @@ export function toolFor(command, { outDir }) {
82
101
  let description = command.description
83
102
  if (bytesKey) {
84
103
  delete input.properties[bytesKey]
85
- input.properties.path = inPath
104
+ const pick = FOLDER_PICKS[command.name]
105
+ input.properties.path = pick ? { ...inPath, description: `${inPath.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : inPath
86
106
  required = required.filter((key) => key !== bytesKey && key !== 'name')
87
107
  if (!required.includes('path')) required.unshift('path')
88
108
  if (input.properties.name) {
@@ -91,15 +111,23 @@ export function toolFor(command, { outDir }) {
91
111
  description += ' The file is read from `path` on this computer and streamed to the page.'
92
112
  }
93
113
  if (command.returnsImage) {
94
- input.properties.path = imagePath
95
- description += ' The picture comes back as an image; with `path` it is written to a file too.'
114
+ input.properties.path = imagePath(outDir)
115
+ input.properties.inline = inlineIn
116
+ description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
96
117
  } else if (command.returnsFile) {
97
118
  input.properties.path = outPath(outDir)
98
119
  description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
99
120
  }
121
+ if (replacesSession(command)) {
122
+ input.properties.saveTo = saveToIn
123
+ description += ' With `saveTo`, the session is saved as a project file there first (as project_save does), then replaced as with discard: true — one call to keep what a page holds and start anew.'
124
+ }
100
125
  if (!command.readOnly) {
101
126
  input.properties.wait = waitIn
102
127
  input.properties.timings = timingsIn
128
+ } else if (command.long) {
129
+ // A long read runs as a job too (mcp.js), so it takes wait.
130
+ input.properties.wait = waitIn
103
131
  }
104
132
  if (required.length) input.required = required
105
133
  else delete input.required
@@ -115,41 +143,188 @@ export function toolFor(command, { outDir }) {
115
143
  /** A path as the agent gave it, made absolute against `base`. */
116
144
  export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
117
145
 
146
+ /** What a folder given in place of a file opens, by command: the one file
147
+ * of its kind in it. Other commands take a file only. */
148
+ const FOLDER_PICKS = {
149
+ 'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj'] },
150
+ 'project.load': { what: 'project', kinds: ['.scanruler'] },
151
+ }
152
+
153
+ /** The file a path names: itself, or — for a folder, where the command
154
+ * takes one (FOLDER_PICKS) — the only file of the command's kind in it.
155
+ * Refused with the folder's files listed otherwise. */
156
+ async function fileAt(command, file) {
157
+ const found = await stat(file).catch(() => null)
158
+ if (!found?.isDirectory()) return file
159
+ const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
160
+ const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
161
+ const pick = FOLDER_PICKS[command.name]
162
+ if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
163
+ const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
164
+ if (matches.length === 1) return join(file, matches[0])
165
+ if (matches.length === 0) {
166
+ throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
167
+ }
168
+ throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
169
+ }
170
+
171
+ /** How many near names a missing path's refusal lists at most, and how many
172
+ * letters off a near name may be. */
173
+ const NEAR_LISTED = 5
174
+ const NEAR_LETTERS = 2
175
+
176
+ /** A name as nearNames compares it: case ignored, and "-", "_" and a
177
+ * space taken alike. */
178
+ const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
179
+
180
+ /** How many letters `a` and `b` are apart — added, taken out or changed —
181
+ * or `most + 1` once past `most`. */
182
+ function lettersApart(a, b, most) {
183
+ if (Math.abs(a.length - b.length) > most) return most + 1
184
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j)
185
+ for (let i = 1; i <= a.length; i++) {
186
+ const next = [i]
187
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
188
+ if (Math.min(...next) > most) return most + 1
189
+ row = next
190
+ }
191
+ return row[b.length]
192
+ }
193
+
194
+ /** Where a path that is not there may have been meant: the first folder up
195
+ * from it that is there, the name in the path missing from it, and up to
196
+ * NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
197
+ * path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
198
+ * given with the rest of the path when that is in it. */
199
+ export async function nearNames(path) {
200
+ let missing = resolve(path)
201
+ let folder = dirname(missing)
202
+ let rest = []
203
+ for (;;) {
204
+ if ((await stat(folder).catch(() => null))?.isDirectory()) break
205
+ if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
206
+ rest = [basename(missing), ...rest]
207
+ missing = folder
208
+ folder = dirname(folder)
209
+ }
210
+ const want = nearForm(basename(missing))
211
+ const names = await readdir(folder).catch(() => [])
212
+ const near = names
213
+ .map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
214
+ .filter((n) => n.apart <= NEAR_LETTERS)
215
+ .sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
216
+ .slice(0, NEAR_LISTED)
217
+ const paths = []
218
+ for (const { name } of near) {
219
+ const whole = join(folder, name, ...rest)
220
+ paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
221
+ }
222
+ return { folder, missing: basename(missing), near: paths }
223
+ }
224
+
225
+ /** nearNames as a refusal's last sentence. */
226
+ async function nearNote(path) {
227
+ const { folder, missing, near } = await nearNames(path)
228
+ if (!folder) return ''
229
+ if (near.length) return ` Near it: ${near.join(', ')}.`
230
+ return ` ${folder} is there, with nothing in it named like ${missing}.`
231
+ }
232
+
118
233
  /** The tool call's arguments as the command's input: a path read into the
119
- * bytes it names. Returns the input and the input field the bytes go in. */
234
+ * bytes it names. Returns the input, the input field the bytes go in, and
235
+ * the file read — a folder's one file, when the path named a folder. */
120
236
  export async function inputFor(command, args, { cwd }) {
121
237
  const input = { ...(args ?? {}) }
238
+ delete input.saveTo
122
239
  const bytesKey = bytesProperty(command)
123
240
  if (bytesKey) {
124
- const file = absolute(String(input.path ?? ''), cwd)
241
+ const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
125
242
  delete input.path
126
243
  let bytes
127
244
  try {
128
245
  bytes = new Uint8Array(await readFile(file))
129
246
  } catch (e) {
130
- throw { code: 'invalid_input', message: `Could not read ${file}: ${e.code === 'ENOENT' ? 'there is no such file' : e.message}.` }
247
+ if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
248
+ throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
131
249
  }
132
250
  input[bytesKey] = bytes
133
251
  if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
134
- return { input, binaryKey: bytesKey }
252
+ return { input, binaryKey: bytesKey, file }
135
253
  }
136
254
  if (command.returnsFile || command.returnsImage) delete input.path
137
- return { input, binaryKey: null }
255
+ if (command.returnsImage) delete input.inline
256
+ return { input, binaryKey: null, file: null }
257
+ }
258
+
259
+ /** Arguments with every `{ "$file": path }` in them — an object of that one
260
+ * key, at any depth — replaced by the JSON the file holds: a long list of
261
+ * stations a script wrote need not pass through the conversation. A
262
+ * relative path is taken from `cwd`. What a file holds is not looked
263
+ * into again. */
264
+ export async function withFiles(value, cwd) {
265
+ if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
266
+ if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
267
+ const keys = Object.keys(value)
268
+ if (keys.length === 1 && keys[0] === '$file') {
269
+ if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
270
+ const path = absolute(value.$file, cwd)
271
+ try {
272
+ return JSON.parse(await readFile(path, 'utf8'))
273
+ } catch (e) {
274
+ throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
275
+ }
276
+ }
277
+ const out = {}
278
+ for (const key of keys) out[key] = await withFiles(value[key], cwd)
279
+ return out
280
+ }
281
+
282
+ /** A file's kind by its extension, as scanruler_files lists it. */
283
+ const KINDS = {
284
+ mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
285
+ project: ['.scanruler'],
286
+ step: ['.step', '.stp'],
287
+ picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
288
+ drawing: ['.dxf', '.svg', '.pdf'],
289
+ text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
290
+ }
291
+ export const fileKind = (name) => {
292
+ const ext = extname(name).toLowerCase()
293
+ return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
138
294
  }
139
295
 
140
296
  /** A picture a command handed back, as MCP content: the image, and the rest
141
297
  * of the result as text — where the picture was written, if the call gave
142
- * a path, in place of its bytes. Null for a result with no picture in it. */
298
+ * a path, in place of its bytes; written and not shown with `inline:
299
+ * false`. Null for a result with no picture in it. */
143
300
  export async function imageContent(result, args, { cwd, outDir }) {
144
301
  const file = result?.file
145
302
  if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
146
303
  const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
304
+ if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
147
305
  return [
148
306
  { type: 'text', text: agentJson(saved) },
149
307
  { type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
150
308
  ]
151
309
  }
152
310
 
311
+ /** A picture a batched call handed back, which a batch cannot show:
312
+ * written where the call asked, or into `outDir` under the name ScanRuler
313
+ * gave it — numbered past one already there, so a batch of renders, or a
314
+ * render from an earlier batch, is not written over. The result says
315
+ * where, in place of the bytes. A result with no picture in it is as
316
+ * saveFileResult leaves it. */
317
+ export async function batchedPicture(result, args, { cwd, outDir }) {
318
+ const file = result?.file
319
+ if (args?.path || !file || !(file.bytes instanceof Uint8Array)) return saveFileResult(result, args, { cwd, outDir })
320
+ const name = String(file.name ?? 'picture.png')
321
+ const dot = name.lastIndexOf('.')
322
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
323
+ let target = join(outDir, name)
324
+ for (let n = 2; await stat(target).catch(() => null); n++) target = join(outDir, `${stem}-${n}${ext}`)
325
+ return saveFileResult(result, { path: target }, { cwd, outDir })
326
+ }
327
+
153
328
  /** Write a returned file where the call asked, or into `outDir`; the result
154
329
  * then says where, in place of the bytes. */
155
330
  export async function saveFileResult(result, args, { cwd, outDir }) {
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
+ }