@commonlyai/cli 0.1.78 → 0.1.79
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/package.json +1 -1
- package/src/lib/mcp-home.js +99 -9
- package/src/lib/mcp-server-version.js +22 -0
- package/src/lib/mcp-warm-child.mjs +19 -11
package/package.json
CHANGED
package/src/lib/mcp-home.js
CHANGED
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
* operator saying which build to run, and the npx cache already gives a pinned
|
|
28
28
|
* spec its own dir.
|
|
29
29
|
*
|
|
30
|
+
* The cold path is pinned too (TASK-174's second half). A spawn with no usable
|
|
31
|
+
* home still runs the declaration, but not with the shared `@latest` spec: the
|
|
32
|
+
* spec this spawn executes is the version the registry already answered with,
|
|
33
|
+
* read from the warm's own cache, so `@latest`'s one shared npx dir is not on
|
|
34
|
+
* the spawn path even once. The fallback is the declaration as written, and it
|
|
35
|
+
* is used exactly when nothing has ever resolved a version on this host — a
|
|
36
|
+
* fresh machine's first spawn, where `@latest` is also the only spec that could
|
|
37
|
+
* work — or when the cache is unreadable. Nothing here throws.
|
|
38
|
+
*
|
|
30
39
|
* Every filesystem answer here is injected, because the two states that matter
|
|
31
40
|
* (a home that has never been warmed, and a home whose pointed-at version dir was
|
|
32
41
|
* deleted under it) cannot both be produced on one host.
|
|
@@ -39,7 +48,7 @@ import { homedir } from 'node:os';
|
|
|
39
48
|
import { dirname, join, sep } from 'node:path';
|
|
40
49
|
import { fileURLToPath } from 'node:url';
|
|
41
50
|
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
42
|
-
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
51
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
43
52
|
|
|
44
53
|
/** The env override, so tests (and an operator) can point the home elsewhere. */
|
|
45
54
|
export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
|
|
@@ -113,8 +122,34 @@ export const isUsableWarmHome = (home) => (
|
|
|
113
122
|
export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
|
|
114
123
|
|
|
115
124
|
export const currentPointerPath = (home) => join(home, 'current');
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where a version dir name that is not a triple lands: inside the home, and
|
|
128
|
+
* never a version (a version starts with a digit), so every read of it misses
|
|
129
|
+
* and the spawn falls back to its declaration. The alternative — building the
|
|
130
|
+
* path from whatever string arrived — is how `0.3.13/../../../../tmp/x` resolved
|
|
131
|
+
* outside the home, and #1970's `startsWith(pkgDir)` confinement is computed
|
|
132
|
+
* FROM that dir, so it would have confined to the wrong place (Vera, 74938).
|
|
133
|
+
*/
|
|
134
|
+
const UNUSABLE_VERSION_DIR = '.unusable-version';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A version dir, built from the parsed triple — and only when the string given
|
|
138
|
+
* IS that triple.
|
|
139
|
+
*
|
|
140
|
+
* Refusing beats rebuilding here: this result is what the warm RENAMES into and
|
|
141
|
+
* what the pruner REMOVES, so silently rewriting `0.3.14-rc.1` to `0.3.14` would
|
|
142
|
+
* aim both at a different build. Normalising belongs at the producer of a
|
|
143
|
+
* version value (`resolveRegistryLatest`, `readCurrentVersion`,
|
|
144
|
+
* `readRegistryCache`, `pinShippedSpec`), where the choice is deliberate and
|
|
145
|
+
* visible; a producer that names something else gets a name no version can have.
|
|
146
|
+
*/
|
|
147
|
+
export const versionDirFor = (home, version) => {
|
|
148
|
+
const triple = exactVersion(version);
|
|
149
|
+
const name = triple && triple === String(version).trim() ? triple : UNUSABLE_VERSION_DIR;
|
|
150
|
+
return join(home, name);
|
|
151
|
+
};
|
|
152
|
+
export const packageDirFor = (home, version) => join(versionDirFor(home, version), 'node_modules', MCP_PACKAGE);
|
|
118
153
|
export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
|
|
119
154
|
|
|
120
155
|
/** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
|
|
@@ -236,8 +271,8 @@ const defaultExists = (path) => {
|
|
|
236
271
|
export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
|
|
237
272
|
const raw = readFile(currentPointerPath(home));
|
|
238
273
|
if (typeof raw !== 'string') return null;
|
|
239
|
-
const version = raw.trim();
|
|
240
|
-
return
|
|
274
|
+
const version = exactVersion(raw.trim());
|
|
275
|
+
return version || null;
|
|
241
276
|
};
|
|
242
277
|
|
|
243
278
|
/**
|
|
@@ -282,6 +317,9 @@ const readBinInPackageDir = (pkgDir, {
|
|
|
282
317
|
|
|
283
318
|
/** The bin of a version dir inside the seat's home. */
|
|
284
319
|
export const readBinPath = (home, version, options = {}) => {
|
|
320
|
+
// Truthiness of `parseVersion` is truthiness of `exactVersion`; the check that
|
|
321
|
+
// matters here is `versionDirFor` refusing a name that is not a triple, so a
|
|
322
|
+
// reader naming `0.3.14-rc.1` gets nothing rather than 0.3.14's build.
|
|
285
323
|
if (!parseVersion(version)) return null;
|
|
286
324
|
return readBinInPackageDir(packageDirFor(home, version), options);
|
|
287
325
|
};
|
|
@@ -317,7 +355,10 @@ export const newestInstalledVersion = (home, {
|
|
|
317
355
|
readBin = (h, v) => readBinPath(h, v),
|
|
318
356
|
} = {}) => {
|
|
319
357
|
const versions = readDir(home)
|
|
320
|
-
|
|
358
|
+
// An exact triple, not a prefix match: a dir literally named `0.3.13-rc.1`
|
|
359
|
+
// is not `0.3.13`, and normalising the name would point this read at a dir
|
|
360
|
+
// the caller never mentioned.
|
|
361
|
+
.filter((name) => exactVersion(name) === name)
|
|
321
362
|
.filter((name) => readBin(home, name))
|
|
322
363
|
.sort((a, b) => {
|
|
323
364
|
const [am, an, ap] = parseVersion(a);
|
|
@@ -332,6 +373,39 @@ const isUnpinnedShippedSpec = (spec) => (
|
|
|
332
373
|
spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
|
|
333
374
|
);
|
|
334
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The shipped command with its spec pinned to `version`, or the command
|
|
378
|
+
* unchanged when there is no version to pin to.
|
|
379
|
+
*
|
|
380
|
+
* Only slot 2 is touched, and only after `isShippedCommonlyMcpCommand` has
|
|
381
|
+
* proven the slot is the package spec — so `npx`, its `-y` and every other
|
|
382
|
+
* entry survive byte-for-byte, and a command that is not the shipped 3-arg form
|
|
383
|
+
* cannot be rewritten by construction. npx keys its cache dir by the spec
|
|
384
|
+
* string, which is the whole point: `@latest` is one dir every seat shares and a
|
|
385
|
+
* publish rewrites in place, while `@0.3.13` is a dir only this version ever
|
|
386
|
+
* uses.
|
|
387
|
+
*/
|
|
388
|
+
export const pinShippedSpec = (command, version) => {
|
|
389
|
+
// The triple, not the string: this spec is an argv entry we execute, so a
|
|
390
|
+
// version carrying a suffix must not reach it.
|
|
391
|
+
const safe = exactVersion(version);
|
|
392
|
+
if (!Array.isArray(command) || !safe) return command;
|
|
393
|
+
let changed = false;
|
|
394
|
+
const pinned = command.map((part, index) => {
|
|
395
|
+
// Slot 2 only. `isShippedCommonlyMcpCommand` already proves the package sits
|
|
396
|
+
// in slot 2 of a 3-arg command, so this bound is not reachable today — it is
|
|
397
|
+
// here so the function is safe to read on its own, and it is armed in the
|
|
398
|
+
// tests rather than argued from the shape check (Vera, 74940).
|
|
399
|
+
if (index !== 2 || !isUnpinnedShippedSpec(String(part))) return part;
|
|
400
|
+
changed = true;
|
|
401
|
+
return `${MCP_PACKAGE}@${safe}`;
|
|
402
|
+
});
|
|
403
|
+
// The same reference when nothing changed, so a caller can use identity as the
|
|
404
|
+
// "did this spawn's spec move" check — the convention the rest of this module
|
|
405
|
+
// already keeps (`ensureCommonlyMcpServer`, `withholdGrantBroker`).
|
|
406
|
+
return changed ? pinned : command;
|
|
407
|
+
};
|
|
408
|
+
|
|
335
409
|
/**
|
|
336
410
|
* What a spawn should execute, and where that decision came from.
|
|
337
411
|
*
|
|
@@ -370,7 +444,23 @@ export const planMcpSpawn = (command, {
|
|
|
370
444
|
};
|
|
371
445
|
}
|
|
372
446
|
}
|
|
373
|
-
|
|
447
|
+
// No usable home, so the declaration is what runs — and the one thing worth
|
|
448
|
+
// changing about it is the spec string. The cached registry answer is used
|
|
449
|
+
// rather than a fresh lookup because this function is synchronous and on the
|
|
450
|
+
// spawn's critical path, and the warm refreshes that same cache on the same
|
|
451
|
+
// TTL (`REGISTRY_TTL_MS`). A stale-but-real version still gives this run its
|
|
452
|
+
// own npx dir, which `@latest` does not, and a stale cache means the registry
|
|
453
|
+
// has been answering nothing for a while — the case where `@latest` is least
|
|
454
|
+
// usable. `pinned` is reported so the caller can log which spec it ran.
|
|
455
|
+
const cached = readRegistryCache(target, { readFile });
|
|
456
|
+
const pinned = cached ? pinShippedSpec(command, cached.version) : command;
|
|
457
|
+
return {
|
|
458
|
+
command: pinned,
|
|
459
|
+
source: 'declared',
|
|
460
|
+
version: null,
|
|
461
|
+
reason: 'home-empty',
|
|
462
|
+
pinned: cached ? cached.version : null,
|
|
463
|
+
};
|
|
374
464
|
};
|
|
375
465
|
|
|
376
466
|
/** The warm's cached registry answer, with its age. */
|
|
@@ -379,9 +469,9 @@ export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
|
|
|
379
469
|
if (typeof raw !== 'string') return null;
|
|
380
470
|
try {
|
|
381
471
|
const parsed = JSON.parse(raw);
|
|
382
|
-
const version = parsed &&
|
|
472
|
+
const version = exactVersion(parsed && parsed.version);
|
|
383
473
|
const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
|
|
384
|
-
if (!
|
|
474
|
+
if (!version || checkedAt === null) return null;
|
|
385
475
|
return { version, checkedAt };
|
|
386
476
|
} catch {
|
|
387
477
|
return null;
|
|
@@ -23,6 +23,28 @@ export const parseVersion = (spec) => {
|
|
|
23
23
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The version as a bare triple — or null when there is none to build.
|
|
28
|
+
*
|
|
29
|
+
* `parseVersion` is a PREFIX test, which is right for ordering two versions and
|
|
30
|
+
* wrong for building anything out of one: it accepts `0.3.13/../../../../tmp/x`,
|
|
31
|
+
* so a version read out of a file (`~/.commonly/mcp/.registry.json`, the
|
|
32
|
+
* `current` pointer) or out of `npm view` could become a path or an npm SPEC
|
|
33
|
+
* ARGUMENT that leaves the seat's home. Everything that turns a version into a
|
|
34
|
+
* STRING used outside this module's own comparisons goes through here instead,
|
|
35
|
+
* and the string it builds is the parsed triple, so no suffix can travel.
|
|
36
|
+
*
|
|
37
|
+
* The cost is deliberate and one-directional: a prerelease (`0.3.13-rc.1`)
|
|
38
|
+
* becomes `0.3.13` rather than falling back to `@latest` — the stable release of
|
|
39
|
+
* the same triple, which is a real published version, and strictly better than
|
|
40
|
+
* the shared `@latest` dir this whole change exists to get off. Anchoring the
|
|
41
|
+
* regex at the end instead would reject the prerelease and leave it unpinned.
|
|
42
|
+
*/
|
|
43
|
+
export const exactVersion = (spec) => {
|
|
44
|
+
const triple = parseVersion(spec);
|
|
45
|
+
return triple ? triple.join('.') : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* True when `version` is older than `target`; null when there is no version to
|
|
28
50
|
* judge (an unpinned `@latest` tracks the published release, so it is never
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
WARM_RESULTS, currentPointerPath, isUsableWarmHome, liveInUsePids, lockPathFor,
|
|
35
35
|
readBinInPrefix, versionDirFor,
|
|
36
36
|
} from './mcp-home.js';
|
|
37
|
-
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
37
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
38
38
|
|
|
39
39
|
export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
|
|
40
40
|
export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
|
|
@@ -147,9 +147,9 @@ export const resolveRegistryLatest = async (home, {
|
|
|
147
147
|
if (cached) {
|
|
148
148
|
try {
|
|
149
149
|
const parsed = JSON.parse(cached);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { version:
|
|
150
|
+
const cachedVersion = parsed ? exactVersion(parsed.version) : null;
|
|
151
|
+
if (cachedVersion && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs) {
|
|
152
|
+
return { version: cachedVersion, source: 'cache' };
|
|
153
153
|
}
|
|
154
154
|
} catch { /* unreadable cache is just a miss */ }
|
|
155
155
|
}
|
|
@@ -164,18 +164,24 @@ export const resolveRegistryLatest = async (home, {
|
|
|
164
164
|
version = raw.replace(/^"|"$/g, '');
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
-
|
|
167
|
+
// This answer is both persisted and, downstream, an argv entry
|
|
168
|
+
// (`npm install <pkg>@<version>`) and a path (`<home>/<version>`, the temp
|
|
169
|
+
// prefix). Rebuild it as a triple here, at the one place it enters, so the
|
|
170
|
+
// rest of the warm can keep treating a version as a name.
|
|
171
|
+
const safe = exactVersion(version);
|
|
172
|
+
if (safe) {
|
|
168
173
|
try {
|
|
169
|
-
writeFile(cachePath, JSON.stringify({ version, checkedAt: now }));
|
|
174
|
+
writeFile(cachePath, JSON.stringify({ version: safe, checkedAt: now }));
|
|
170
175
|
} catch { /* a cache we cannot write is not a warm we should fail */ }
|
|
171
|
-
return { version, source: 'registry' };
|
|
176
|
+
return { version: safe, source: 'registry' };
|
|
172
177
|
}
|
|
173
178
|
// Registry down: the last known answer is better than none, and better than
|
|
174
179
|
// moving the pointer backwards.
|
|
175
180
|
if (cached) {
|
|
176
181
|
try {
|
|
177
182
|
const parsed = JSON.parse(cached);
|
|
178
|
-
|
|
183
|
+
const stale = parsed ? exactVersion(parsed.version) : null;
|
|
184
|
+
if (stale) return { version: stale, source: 'stale-cache' };
|
|
179
185
|
} catch { /* fall through */ }
|
|
180
186
|
}
|
|
181
187
|
return { version: null, source: 'unavailable' };
|
|
@@ -267,7 +273,10 @@ export const pruneVersionDirs = (home, current, {
|
|
|
267
273
|
},
|
|
268
274
|
livePids = (version) => liveInUsePids(home, version),
|
|
269
275
|
} = {}) => {
|
|
270
|
-
|
|
276
|
+
// Exact triples only, for the same reason `newestInstalledVersion` uses them:
|
|
277
|
+
// a dir named `0.3.13-rc.1` is not the dir `0.3.13`, and this loop REMOVES what
|
|
278
|
+
// it names.
|
|
279
|
+
const versions = readDir(home).filter((name) => exactVersion(name) === name);
|
|
271
280
|
versions.sort((a, b) => {
|
|
272
281
|
const [am, an, ap] = parseVersion(a);
|
|
273
282
|
const [bm, bn, bp] = parseVersion(b);
|
|
@@ -309,8 +318,7 @@ export const warmMcpHome = async (home, {
|
|
|
309
318
|
readCurrent = (h) => {
|
|
310
319
|
const raw = readIfAny(currentPointerPath(h));
|
|
311
320
|
if (typeof raw !== 'string') return null;
|
|
312
|
-
|
|
313
|
-
return parseVersion(trimmed) ? trimmed : null;
|
|
321
|
+
return exactVersion(raw.trim()) || null;
|
|
314
322
|
},
|
|
315
323
|
keep = KEEP_VERSION_DIRS,
|
|
316
324
|
} = {}) => {
|